esoul-sdk 0.18.0 → 0.19.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/api-reference.md +267 -11
- package/bin/esoul-device-program.mjs +146 -0
- package/dist/device.d.ts +25 -0
- package/dist/machine/capabilities.d.ts +12 -1
- package/dist/machine/capabilities.js +36 -2
- package/dist/machine/commands.d.ts +42 -0
- package/dist/machine/commands.js +56 -4
- package/dist/machine/digest.d.ts +14 -2
- package/dist/machine/digest.js +32 -3
- package/dist/machine/index.d.ts +5 -3
- package/dist/machine/index.js +5 -3
- package/dist/machine/program-api.d.ts +101 -0
- package/dist/machine/program-api.js +22 -0
- package/dist/machine/program-manager.d.ts +61 -0
- package/dist/machine/program-manager.js +600 -0
- package/dist/machine/service.d.ts +6 -0
- package/dist/machine/service.js +56 -5
- package/dist/machine/supervisor.d.ts +4 -0
- package/dist/machine/supervisor.js +19 -3
- package/dist/manifest.d.ts +236 -85
- package/dist/manifest.js +22 -1
- package/dist/server.d.ts +11 -1
- package/docs/18-your-computer.md +120 -5
- package/llms-full.txt +120 -5
- package/package.json +1 -1
- package/schemas/plugin.schema.json +79 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The process an app's PROGRAM runs in (computer-apps.md §4.1). The runtime
|
|
3
|
+
// starts it with an IPC channel, inside the link's sandbox, and relays:
|
|
4
|
+
//
|
|
5
|
+
// runtime → here { t: "init", ctx } once, first
|
|
6
|
+
// { t: "msg", id, topic, data } a message from the app
|
|
7
|
+
// { t: "opResult", id, ok, result, error } the answer to p.op
|
|
8
|
+
// { t: "stop" } stop cleanly
|
|
9
|
+
// here → runtime { t: "started" } | { t: "status", text, progress, ready }
|
|
10
|
+
// { t: "op", id, name, args, key } call one of the app's ops
|
|
11
|
+
// { t: "msgResult", id, ok, result, error }
|
|
12
|
+
// { t: "log", line } | { t: "fatal", error }
|
|
13
|
+
//
|
|
14
|
+
// Plain JavaScript with no imports beyond node: — it runs from any copy of the
|
|
15
|
+
// package with nothing built. The program's entry may be TypeScript; the
|
|
16
|
+
// runtime starts Node with type stripping for a .ts entry.
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { format } from "node:util";
|
|
20
|
+
|
|
21
|
+
const [programDir, entry] = process.argv.slice(2);
|
|
22
|
+
if (!programDir || !entry || typeof process.send !== "function") {
|
|
23
|
+
console.error("usage: started by esoul-device with an IPC channel: esoul-device-program <program-dir> <entry>");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
const send = (m) => {
|
|
27
|
+
try {
|
|
28
|
+
process.send(m);
|
|
29
|
+
} catch {
|
|
30
|
+
/* the runtime is gone; the process exits with it */
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const handlers = new Map();
|
|
34
|
+
const pendingOps = new Map();
|
|
35
|
+
const timers = new Set();
|
|
36
|
+
let nextId = 1;
|
|
37
|
+
let program = null;
|
|
38
|
+
let stopping = false;
|
|
39
|
+
|
|
40
|
+
const logLine = (...a) => send({ t: "log", line: format(...a).slice(0, 4000) });
|
|
41
|
+
console.log = logLine;
|
|
42
|
+
console.info = logLine;
|
|
43
|
+
console.warn = (...a) => logLine("warn:", ...a);
|
|
44
|
+
console.error = (...a) => logLine("error:", ...a);
|
|
45
|
+
|
|
46
|
+
function api(ctx) {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
computer: Object.freeze({ ...ctx.computer }),
|
|
49
|
+
appName: ctx.appName,
|
|
50
|
+
programDir,
|
|
51
|
+
dataDir: ctx.dataDir,
|
|
52
|
+
workspaceRoot: ctx.workspaceRoot ?? null,
|
|
53
|
+
config: Object.freeze({ ...ctx.config }),
|
|
54
|
+
status(text, extra = {}) {
|
|
55
|
+
send({ t: "status", text: String(text).slice(0, 300), progress: typeof extra.progress === "number" ? Math.max(0, Math.min(1, extra.progress)) : undefined, ready: extra.ready === true ? true : undefined });
|
|
56
|
+
},
|
|
57
|
+
op(name, args, opts = {}) {
|
|
58
|
+
const id = nextId++;
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
pendingOps.set(id, { resolve, reject });
|
|
61
|
+
send({ t: "op", id, name: String(name), args: args ?? null, key: opts.key ?? null });
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
on(topic, handler) {
|
|
65
|
+
if (typeof handler !== "function") throw new TypeError(`p.on("${topic}") needs a function`);
|
|
66
|
+
handlers.set(String(topic), handler);
|
|
67
|
+
},
|
|
68
|
+
every(ms, fn) {
|
|
69
|
+
const period = Math.max(1000, Number(ms) || 0);
|
|
70
|
+
let busy = false;
|
|
71
|
+
const tick = async () => {
|
|
72
|
+
if (busy || stopping) return;
|
|
73
|
+
busy = true;
|
|
74
|
+
try {
|
|
75
|
+
await fn();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
logLine(`error: every(${period}) failed: ${e?.stack ?? e}`);
|
|
78
|
+
} finally {
|
|
79
|
+
busy = false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const t = setInterval(tick, period);
|
|
83
|
+
timers.add(t);
|
|
84
|
+
return () => {
|
|
85
|
+
clearInterval(t);
|
|
86
|
+
timers.delete(t);
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
log: logLine,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function stop(code = 0) {
|
|
94
|
+
if (stopping) return;
|
|
95
|
+
stopping = true;
|
|
96
|
+
for (const t of timers) clearInterval(t);
|
|
97
|
+
try {
|
|
98
|
+
await program?.stop?.();
|
|
99
|
+
} catch (e) {
|
|
100
|
+
logLine(`error: stop failed: ${e?.stack ?? e}`);
|
|
101
|
+
}
|
|
102
|
+
setTimeout(() => process.exit(code), 50);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
process.on("message", async (m) => {
|
|
106
|
+
if (!m || typeof m !== "object") return;
|
|
107
|
+
if (m.t === "init") {
|
|
108
|
+
try {
|
|
109
|
+
const mod = await import(pathToFileURL(join(programDir, entry)).href);
|
|
110
|
+
program = mod.default ?? mod.program;
|
|
111
|
+
if (!program || typeof program.start !== "function") throw new Error(`${entry} must export default { start(p) { … } }`);
|
|
112
|
+
await program.start(api(m.ctx));
|
|
113
|
+
send({ t: "started" });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
send({ t: "fatal", error: String(e?.stack ?? e).slice(0, 4000) });
|
|
116
|
+
setTimeout(() => process.exit(1), 50);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (m.t === "opResult") {
|
|
121
|
+
const p = pendingOps.get(m.id);
|
|
122
|
+
if (!p) return;
|
|
123
|
+
pendingOps.delete(m.id);
|
|
124
|
+
if (m.ok) p.resolve(m.result);
|
|
125
|
+
else p.reject(new Error(m.error ?? "the op failed"));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (m.t === "msg") {
|
|
129
|
+
const h = handlers.get(m.topic);
|
|
130
|
+
if (!h) {
|
|
131
|
+
send({ t: "msgResult", id: m.id, ok: false, error: `this program has no handler for "${m.topic}" (p.on("${m.topic}", …))` });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const result = await h(m.data);
|
|
136
|
+
send({ t: "msgResult", id: m.id, ok: true, result: result === undefined ? null : result });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
send({ t: "msgResult", id: m.id, ok: false, error: String(e?.message ?? e).slice(0, 2000) });
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (m.t === "stop") void stop(0);
|
|
143
|
+
});
|
|
144
|
+
process.on("disconnect", () => void stop(0));
|
|
145
|
+
process.on("SIGTERM", () => void stop(0));
|
|
146
|
+
process.on("unhandledRejection", (e) => logLine(`error: unhandled rejection: ${e?.stack ?? e}`));
|
package/dist/device.d.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* const { commandId } = await d.run("train", { epochs: 3 });
|
|
12
12
|
* 3. its UI shows the run live with useDeviceRun({ workspaceId, nodeId, commandId }).
|
|
13
13
|
*/
|
|
14
|
+
import type { ProgramState } from "./machine/program-api.js";
|
|
15
|
+
export type { ProgramState, ProgramStepState } from "./machine/program-api.js";
|
|
14
16
|
export type DeviceRunStatus = "queued" | "claimed" | "running" | "done" | "expired" | "cancelled" | "lost";
|
|
15
17
|
/** What an app's agents may reach on a computer through the shared command line. */
|
|
16
18
|
export type WorkspaceGrant = {
|
|
@@ -56,6 +58,10 @@ export interface DeviceSummary {
|
|
|
56
58
|
} | null;
|
|
57
59
|
/** What this app's agents may reach on that computer (null = declared commands only). */
|
|
58
60
|
workspace?: WorkspaceGrant | null;
|
|
61
|
+
/** Whether the owner approved this app's program on that computer. */
|
|
62
|
+
program?: boolean;
|
|
63
|
+
/** Where the app's program stands there (installing, setup, running, failed…), as the computer last reported it. */
|
|
64
|
+
programState?: ProgramState | null;
|
|
59
65
|
}
|
|
60
66
|
export interface DeviceRunStarted {
|
|
61
67
|
commandId: string;
|
|
@@ -131,6 +137,25 @@ export interface Devices {
|
|
|
131
137
|
commandId: string;
|
|
132
138
|
status: DeviceRunStatus;
|
|
133
139
|
}>;
|
|
140
|
+
/**
|
|
141
|
+
* The app's own program on one computer (plugin.json `device.program`,
|
|
142
|
+
* computer-apps.md §4.1). `send(topic, data)` hands the program a message;
|
|
143
|
+
* its `p.on(topic)` handler's return value is the answer. Waits up to
|
|
144
|
+
* `waitSeconds` (default 30, ≤ 55); with 0 it returns at once and the answer
|
|
145
|
+
* arrives on the run (`get`/`wait`).
|
|
146
|
+
*/
|
|
147
|
+
program(linkId: string): {
|
|
148
|
+
send(topic: string, data?: unknown, opts?: {
|
|
149
|
+
waitSeconds?: number;
|
|
150
|
+
}): Promise<{
|
|
151
|
+
commandId: string;
|
|
152
|
+
status: DeviceRunStatus;
|
|
153
|
+
ok: boolean;
|
|
154
|
+
result: unknown;
|
|
155
|
+
error: string | null;
|
|
156
|
+
}>;
|
|
157
|
+
state(): Promise<ProgramState | null>;
|
|
158
|
+
};
|
|
134
159
|
}
|
|
135
160
|
/** The statuses a run ends in: done, expired, cancelled, lost. */
|
|
136
161
|
export declare const DEVICE_RUN_TERMINAL: readonly DeviceRunStatus[];
|
|
@@ -64,12 +64,23 @@ export interface WorkspaceJobParams {
|
|
|
64
64
|
}
|
|
65
65
|
/** The shared command line, under grant ∩ ceiling, one working directory per named agent. */
|
|
66
66
|
export declare function workspaceCapability(): Capability;
|
|
67
|
+
/** The part of the program manager a message job needs (program-manager.ts). */
|
|
68
|
+
export interface ProgramDelivery {
|
|
69
|
+
has(linkId: string): boolean;
|
|
70
|
+
deliver(linkId: string, topic: string, data: unknown, signal?: AbortSignal): Promise<{
|
|
71
|
+
ok: boolean;
|
|
72
|
+
result?: unknown;
|
|
73
|
+
error?: string;
|
|
74
|
+
}>;
|
|
75
|
+
}
|
|
76
|
+
/** A message from the app to its program on this computer; the program's answer is the job's result. */
|
|
77
|
+
export declare function programCapability(programs: ProgramDelivery | null): Capability;
|
|
67
78
|
/** The capability table the runtime dispatches jobs through, by name. */
|
|
68
79
|
export declare function registry(caps: Capability[]): Map<string, Capability>;
|
|
69
80
|
/** What the hello announces: ["commands@1", "workspace@1"]. */
|
|
70
81
|
export declare function announce(reg: Map<string, Capability>): string[];
|
|
71
82
|
/** The capabilities this runtime is built with, as one registry (the supervisor dispatches through it). */
|
|
72
|
-
export declare function builtInCapabilities(startRun: (job: Job, redact: string[], env: Record<string, string>) => Promise<void
|
|
83
|
+
export declare function builtInCapabilities(startRun: (job: Job, redact: string[], env: Record<string, string>) => Promise<void>, programs?: ProgramDelivery | null): Map<string, Capability>;
|
|
73
84
|
/**
|
|
74
85
|
* What this runtime can do, said at CONNECT as well as in every hello, so the
|
|
75
86
|
* platform knows it from the approval on: work asked for before the runtime's
|
|
@@ -187,6 +187,40 @@ export function workspaceCapability() {
|
|
|
187
187
|
},
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
|
+
/** A message from the app to its program on this computer; the program's answer is the job's result. */
|
|
191
|
+
export function programCapability(programs) {
|
|
192
|
+
return {
|
|
193
|
+
name: "program",
|
|
194
|
+
version: 1,
|
|
195
|
+
refuse(job, ctx) {
|
|
196
|
+
if (!programs)
|
|
197
|
+
return "this runtime runs no programs";
|
|
198
|
+
if (!ctx.link.commands?.program?.digest)
|
|
199
|
+
return "the owner has not approved a program for this app on this computer";
|
|
200
|
+
if (!programs.has(job.linkId))
|
|
201
|
+
return "the approved program is not installed here yet";
|
|
202
|
+
return null;
|
|
203
|
+
},
|
|
204
|
+
async start(job, ctx) {
|
|
205
|
+
markStart(ctx.home, job.commandId);
|
|
206
|
+
const ac = new AbortController();
|
|
207
|
+
ctx.signal.addEventListener("abort", () => ac.abort());
|
|
208
|
+
const up = new OutputUploader(ctx.client, job.commandId, job.output ?? resolveOutput({ mode: "final" }), () => ac.abort());
|
|
209
|
+
await ctx.client.call("POST", `/api/device/m/commands/${job.commandId}/started`, { startedAt: Date.now() }).catch(() => { });
|
|
210
|
+
void (async () => {
|
|
211
|
+
const r = await programs.deliver(job.linkId, job.name, job.params.data ?? null, ac.signal);
|
|
212
|
+
const outcome = r.ok ? { exitCode: 0, result: r.result ?? null } : { exitCode: 1, reason: r.error ?? "the program did not answer", result: null };
|
|
213
|
+
try {
|
|
214
|
+
await up.finish(outcome);
|
|
215
|
+
markDone(ctx.home, job.commandId);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
ctx.log(`program message ${job.commandId}: could not report (${e.message}); it will be reported lost on restart`);
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
190
224
|
/** The capability table the runtime dispatches jobs through, by name. */
|
|
191
225
|
export function registry(caps) {
|
|
192
226
|
return new Map(caps.map((c) => [c.name, c]));
|
|
@@ -196,8 +230,8 @@ export function announce(reg) {
|
|
|
196
230
|
return [...reg.values()].map((c) => `${c.name}@${c.version}`);
|
|
197
231
|
}
|
|
198
232
|
/** The capabilities this runtime is built with, as one registry (the supervisor dispatches through it). */
|
|
199
|
-
export function builtInCapabilities(startRun) {
|
|
200
|
-
return registry([commandsCapability({ startRun }), workspaceCapability()]);
|
|
233
|
+
export function builtInCapabilities(startRun, programs = null) {
|
|
234
|
+
return registry([commandsCapability({ startRun }), workspaceCapability(), programCapability(programs)]);
|
|
201
235
|
}
|
|
202
236
|
/**
|
|
203
237
|
* What this runtime can do, said at CONNECT as well as in every hello, so the
|
|
@@ -101,8 +101,48 @@ export interface ConfigKeySpec {
|
|
|
101
101
|
required?: boolean;
|
|
102
102
|
describe?: string;
|
|
103
103
|
}
|
|
104
|
+
/** One setup step of a program: argv (never a shell line), run once per program digest and declared inputs. */
|
|
105
|
+
export interface SetupStepSpec {
|
|
106
|
+
/** lowercase id, unique in the program: `venv`, `torch`. */
|
|
107
|
+
id: string;
|
|
108
|
+
/** What the step does, in words, shown on the approval and in the progress list. */
|
|
109
|
+
describe: string;
|
|
110
|
+
/** argv run in the program's data folder; `{program}` stands for the program's own folder. */
|
|
111
|
+
run: string[];
|
|
112
|
+
/** Program files whose contents decide whether the step runs again (`device/requirements.txt`). */
|
|
113
|
+
inputs?: string[];
|
|
114
|
+
/** Default 1800 s, at most 6 h. */
|
|
115
|
+
timeoutSeconds?: number;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The app's own program on the computer (`computer-apps.md` §4.1): the files
|
|
119
|
+
* of `device/` shipped per app version, set up once, then started — at
|
|
120
|
+
* approval when `resident`, or when the app first sends it a message.
|
|
121
|
+
*/
|
|
122
|
+
export interface ProgramSpec {
|
|
123
|
+
/** The entry file, under `device/`: `device/main.ts`, `device/main.js`, `device/main.mjs`. */
|
|
124
|
+
main: string;
|
|
125
|
+
/** Kept running from approval on (a monitor), instead of started on the first message. */
|
|
126
|
+
resident?: boolean;
|
|
127
|
+
setup?: SetupStepSpec[];
|
|
128
|
+
/** argv that must exit 0 before the program counts as ready. */
|
|
129
|
+
selfTest?: {
|
|
130
|
+
run: string[];
|
|
131
|
+
timeoutSeconds?: number;
|
|
132
|
+
};
|
|
133
|
+
/** Filled in by the platform's build: the sha256 of the spec and every file. Never written by hand. */
|
|
134
|
+
digest?: string;
|
|
135
|
+
/** Filled in by the platform's build: the files' total size in bytes. */
|
|
136
|
+
size?: number;
|
|
137
|
+
}
|
|
138
|
+
/** A program's files: at most this many bytes in all (setup steps download the heavy things). */
|
|
139
|
+
export declare const PROGRAM_MAX_BYTES: number;
|
|
140
|
+
/** A program's files: at most this many. */
|
|
141
|
+
export declare const PROGRAM_MAX_FILES = 200;
|
|
104
142
|
export interface DeviceBlock {
|
|
105
143
|
commands: Record<string, CommandSpec>;
|
|
144
|
+
/** The app's own program on the computer. */
|
|
145
|
+
program?: ProgramSpec;
|
|
106
146
|
/** Values set ON THE MACHINE (`esoul-device config set`), never sent to the platform. */
|
|
107
147
|
config?: Record<string, ConfigKeySpec>;
|
|
108
148
|
/** Regexes applied to output on the machine before any byte leaves it. */
|
|
@@ -123,6 +163,8 @@ export declare class DeviceCommandError extends Error {
|
|
|
123
163
|
}
|
|
124
164
|
/** Every problem with a `device` block, in words an author can act on. Empty = valid. */
|
|
125
165
|
export declare function deviceBlockProblems(block: unknown): string[];
|
|
166
|
+
/** What is wrong with a program spec, in words (the platform's build adds `digest` and `size`). */
|
|
167
|
+
export declare function programProblems(p: unknown): string[];
|
|
126
168
|
export interface RenderedCommand {
|
|
127
169
|
name: string;
|
|
128
170
|
argv: string[];
|
package/dist/machine/commands.js
CHANGED
|
@@ -37,6 +37,11 @@ export function resolveOutput(spec) {
|
|
|
37
37
|
tailMaxChars: keep === "head" ? 0 : OUTPUT_LIMITS.tailChars,
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
+
/** A program's files: at most this many bytes in all (setup steps download the heavy things). */
|
|
41
|
+
export const PROGRAM_MAX_BYTES = 2 * 1024 * 1024;
|
|
42
|
+
/** A program's files: at most this many. */
|
|
43
|
+
export const PROGRAM_MAX_FILES = 200;
|
|
44
|
+
const SETUP_ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
40
45
|
/** What a command name may look like: lowercase, starts with a letter, up to 40 characters. */
|
|
41
46
|
export const COMMAND_NAME_RE = /^[a-z][a-z0-9_-]{0,39}$/;
|
|
42
47
|
const PARAM_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/;
|
|
@@ -65,10 +70,14 @@ export function deviceBlockProblems(block) {
|
|
|
65
70
|
if (!block || typeof block !== "object")
|
|
66
71
|
return ["device must be an object with `commands`"];
|
|
67
72
|
const b = block;
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
if (b.program !== undefined)
|
|
74
|
+
out.push(...programProblems(b.program));
|
|
75
|
+
if (b.commands !== undefined && (typeof b.commands !== "object" || b.commands === null))
|
|
76
|
+
return [...out, "device.commands must be an object of named commands"];
|
|
77
|
+
if (!b.commands && !b.program)
|
|
78
|
+
return ["device must declare commands, a program, or both"];
|
|
79
|
+
const names = Object.keys(b.commands ?? {});
|
|
80
|
+
if (names.length === 0 && !b.program)
|
|
72
81
|
out.push("device.commands must list at least one command");
|
|
73
82
|
if (names.length > 64)
|
|
74
83
|
out.push("device.commands lists more than 64 commands");
|
|
@@ -163,6 +172,49 @@ export function deviceBlockProblems(block) {
|
|
|
163
172
|
out.push(`device.onAppDeleted must be "kill" or "finish"`);
|
|
164
173
|
return out;
|
|
165
174
|
}
|
|
175
|
+
/** What is wrong with a program spec, in words (the platform's build adds `digest` and `size`). */
|
|
176
|
+
export function programProblems(p) {
|
|
177
|
+
const out = [];
|
|
178
|
+
if (!p || typeof p !== "object")
|
|
179
|
+
return ["device.program must be an object with `main`"];
|
|
180
|
+
const g = p;
|
|
181
|
+
if (typeof g.main !== "string" || !/^device\/[A-Za-z0-9_./-]+\.(ts|js|mjs)$/.test(g.main) || g.main.split("/").includes(".."))
|
|
182
|
+
out.push("device.program.main must be a .ts, .js or .mjs file under device/ (device/main.ts)");
|
|
183
|
+
if (g.resident !== undefined && typeof g.resident !== "boolean")
|
|
184
|
+
out.push("device.program.resident must be true or false");
|
|
185
|
+
const argvOk = (a) => Array.isArray(a) && a.length > 0 && a.every((x) => typeof x === "string") && !!a[0];
|
|
186
|
+
const ids = new Set();
|
|
187
|
+
if (g.setup !== undefined) {
|
|
188
|
+
if (!Array.isArray(g.setup))
|
|
189
|
+
out.push("device.program.setup must be a list of steps");
|
|
190
|
+
else {
|
|
191
|
+
if (g.setup.length > 20)
|
|
192
|
+
out.push("device.program.setup has more than 20 steps");
|
|
193
|
+
g.setup.forEach((st, i) => {
|
|
194
|
+
const at = `device.program.setup[${i}]`;
|
|
195
|
+
if (!st || typeof st !== "object")
|
|
196
|
+
return void out.push(`${at} must be an object`);
|
|
197
|
+
if (typeof st.id !== "string" || !SETUP_ID_RE.test(st.id))
|
|
198
|
+
out.push(`${at}.id must match ${SETUP_ID_RE}`);
|
|
199
|
+
else if (ids.has(st.id))
|
|
200
|
+
out.push(`${at}.id "${st.id}" is used twice`);
|
|
201
|
+
else
|
|
202
|
+
ids.add(st.id);
|
|
203
|
+
if (typeof st.describe !== "string" || !st.describe.trim())
|
|
204
|
+
out.push(`${at}.describe says what the step does, in words`);
|
|
205
|
+
if (!argvOk(st.run))
|
|
206
|
+
out.push(`${at}.run must be a non-empty argv (a list of strings, never a shell line)`);
|
|
207
|
+
if (st.inputs !== undefined && (!Array.isArray(st.inputs) || st.inputs.some((f) => typeof f !== "string" || !f.startsWith("device/"))))
|
|
208
|
+
out.push(`${at}.inputs must list program files under device/`);
|
|
209
|
+
if (st.timeoutSeconds !== undefined && !(Number.isInteger(st.timeoutSeconds) && st.timeoutSeconds > 0 && st.timeoutSeconds <= 6 * 3600))
|
|
210
|
+
out.push(`${at}.timeoutSeconds must be a whole number of seconds up to 21600`);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (g.selfTest !== undefined && (!g.selfTest || !argvOk(g.selfTest.run)))
|
|
215
|
+
out.push("device.program.selfTest.run must be a non-empty argv");
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
166
218
|
function checkValue(name, spec, v) {
|
|
167
219
|
const k = kindOf(spec);
|
|
168
220
|
const o = typeof spec === "object" ? spec : {};
|
package/dist/machine/digest.d.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import type { CommandSpec, DeviceBlock } from "./commands.js";
|
|
1
|
+
import type { CommandSpec, DeviceBlock, ProgramSpec } from "./commands.js";
|
|
2
2
|
/** Stable digest of one command's spec — what the owner approved. */
|
|
3
3
|
export declare function commandDigest(spec: CommandSpec): string;
|
|
4
|
-
/**
|
|
4
|
+
/** Where an approval records the program's digest, beside the commands' (no command name can start with "@"). */
|
|
5
|
+
export declare const PROGRAM_KEY = "@program";
|
|
6
|
+
/** Every declared command's digest, by name, and the program's under `@program` — what an approval records. */
|
|
5
7
|
export declare function commandDigests(block: DeviceBlock): Record<string, string>;
|
|
8
|
+
/** The command names an approval covers (without the program's entry). */
|
|
9
|
+
export declare function approvedCommandNames(approved: Record<string, string>): string[];
|
|
10
|
+
/**
|
|
11
|
+
* The digest of a program: its spec (without the computed fields) and every
|
|
12
|
+
* file, sorted by path. Two builds of the same files give the same digest.
|
|
13
|
+
*/
|
|
14
|
+
export declare function programDigest(spec: Omit<ProgramSpec, "digest" | "size">, files: {
|
|
15
|
+
path: string;
|
|
16
|
+
content: Buffer | Uint8Array | string;
|
|
17
|
+
}[]): string;
|
|
6
18
|
/**
|
|
7
19
|
* What an app update asks of the owner. Removing a command, or leaving one
|
|
8
20
|
* untouched, needs nothing; ADDING a command or CHANGING one is new authority
|
package/dist/machine/digest.js
CHANGED
|
@@ -17,13 +17,35 @@ function stable(v) {
|
|
|
17
17
|
export function commandDigest(spec) {
|
|
18
18
|
return createHash("sha256").update(stable(spec)).digest("hex").slice(0, 32);
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/** Where an approval records the program's digest, beside the commands' (no command name can start with "@"). */
|
|
21
|
+
export const PROGRAM_KEY = "@program";
|
|
22
|
+
/** Every declared command's digest, by name, and the program's under `@program` — what an approval records. */
|
|
21
23
|
export function commandDigests(block) {
|
|
22
24
|
const out = {};
|
|
23
|
-
for (const [n, c] of Object.entries(block.commands))
|
|
25
|
+
for (const [n, c] of Object.entries(block.commands ?? {}))
|
|
24
26
|
out[n] = commandDigest(c);
|
|
27
|
+
if (block.program?.digest)
|
|
28
|
+
out[PROGRAM_KEY] = block.program.digest;
|
|
25
29
|
return out;
|
|
26
30
|
}
|
|
31
|
+
/** The command names an approval covers (without the program's entry). */
|
|
32
|
+
export function approvedCommandNames(approved) {
|
|
33
|
+
return Object.keys(approved).filter((k) => k !== PROGRAM_KEY);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The digest of a program: its spec (without the computed fields) and every
|
|
37
|
+
* file, sorted by path. Two builds of the same files give the same digest.
|
|
38
|
+
*/
|
|
39
|
+
export function programDigest(spec, files) {
|
|
40
|
+
const h = createHash("sha256");
|
|
41
|
+
h.update("esoul-program/1\n");
|
|
42
|
+
h.update(stable({ main: spec.main, resident: !!spec.resident, setup: spec.setup ?? [], selfTest: spec.selfTest ?? null }));
|
|
43
|
+
for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
|
44
|
+
h.update(`\n${f.path}\n`);
|
|
45
|
+
h.update(createHash("sha256").update(f.content).digest("hex"));
|
|
46
|
+
}
|
|
47
|
+
return h.digest("hex");
|
|
48
|
+
}
|
|
27
49
|
/**
|
|
28
50
|
* What an app update asks of the owner. Removing a command, or leaving one
|
|
29
51
|
* untouched, needs nothing; ADDING a command or CHANGING one is new authority
|
|
@@ -32,11 +54,18 @@ export function commandDigests(block) {
|
|
|
32
54
|
export function newAuthority(approved, current) {
|
|
33
55
|
const added = [];
|
|
34
56
|
const changed = [];
|
|
35
|
-
for (const [n, c] of Object.entries(current.commands)) {
|
|
57
|
+
for (const [n, c] of Object.entries(current.commands ?? {})) {
|
|
36
58
|
if (!(n in approved))
|
|
37
59
|
added.push(n);
|
|
38
60
|
else if (approved[n] !== commandDigest(c))
|
|
39
61
|
changed.push(n);
|
|
40
62
|
}
|
|
63
|
+
// A program is new authority the first time and whenever its files change.
|
|
64
|
+
if (current.program?.digest) {
|
|
65
|
+
if (!(PROGRAM_KEY in approved))
|
|
66
|
+
added.push("program");
|
|
67
|
+
else if (approved[PROGRAM_KEY] !== current.program.digest)
|
|
68
|
+
changed.push("program");
|
|
69
|
+
}
|
|
41
70
|
return { added, changed };
|
|
42
71
|
}
|
package/dist/machine/index.d.ts
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* <ConnectComputer/> (esoul-sdk/react) and call devices(ctx) (esoul-sdk/server).
|
|
9
9
|
*/
|
|
10
10
|
export { PROTOCOL, H, MAX_SKEW_MS, canonical, signRequest, verifyRequest, generateDeviceKey, isPublicKey, connectCode, sha256Hex, type DeviceKeyPair, type VerifyFailure } from "./protocol.js";
|
|
11
|
-
export { deviceBlockProblems, renderCommand, resolveOutput, DeviceCommandError, COMMAND_NAME_RE, DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, OUTPUT_LIMITS, type DeviceBlock, type CommandSpec, type ParamSpec, type ConfigKeySpec, type OutputSpec, type ResolvedOutput, type RenderedCommand } from "./commands.js";
|
|
12
|
-
export { commandDigest, commandDigests, newAuthority } from "./digest.js";
|
|
11
|
+
export { deviceBlockProblems, programProblems, renderCommand, resolveOutput, DeviceCommandError, COMMAND_NAME_RE, DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, OUTPUT_LIMITS, PROGRAM_MAX_BYTES, PROGRAM_MAX_FILES, type DeviceBlock, type CommandSpec, type ParamSpec, type ConfigKeySpec, type OutputSpec, type ResolvedOutput, type RenderedCommand, type ProgramSpec, type SetupStepSpec } from "./commands.js";
|
|
12
|
+
export { commandDigest, commandDigests, newAuthority, programDigest, approvedCommandNames, PROGRAM_KEY } from "./digest.js";
|
|
13
|
+
export { defineProgram, type Program, type ProgramApi, type ProgramState, type ProgramStepState } from "./program-api.js";
|
|
14
|
+
export { createProgramManager, programPaths, programEnv, type ProgramManager, type ProgramManagerOptions } from "./program-manager.js";
|
|
13
15
|
export { makeClient, DoorError, isTransient, type DoorClient, type CallTiming, type FetchLike } from "./client.js";
|
|
14
16
|
export { connect, startSupervisor, type ConnectOptions, type ConnectResult, type SupervisorOptions } from "./supervisor.js";
|
|
15
17
|
export { deviceHome, machineFingerprint, type LocalLink, type LocalState } from "./state.js";
|
|
16
18
|
export { execute, openWorkspace, tokenize, sandboxFor, confinedArgv, AGENT_NAME_RE, type ShellResult as MachineShellResult, type ShellCall, type Workspace, type Sandbox } from "./shell.js";
|
|
17
19
|
export { parseGrant, grantFromChoice, effectiveGrant, describeGrant, type WorkspaceGrant, type WorkspaceChoice } from "./grant.js";
|
|
18
20
|
export { resolveInScope, applyEdits, applyPatch, parseUnifiedDiff, FileError, Journal, type Scope, type Edit, type HunkResult, type JournalEntry } from "./files.js";
|
|
19
|
-
export { registry, announce, builtInCapabilities, RUNTIME_CAPABILITIES, commandsCapability, workspaceCapability, OutputUploader, type Capability, type Job, type JobContext, type JobOutcome } from "./capabilities.js";
|
|
21
|
+
export { registry, announce, builtInCapabilities, RUNTIME_CAPABILITIES, commandsCapability, workspaceCapability, programCapability, OutputUploader, type ProgramDelivery, type Capability, type Job, type JobContext, type JobOutcome } from "./capabilities.js";
|
package/dist/machine/index.js
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* <ConnectComputer/> (esoul-sdk/react) and call devices(ctx) (esoul-sdk/server).
|
|
9
9
|
*/
|
|
10
10
|
export { PROTOCOL, H, MAX_SKEW_MS, canonical, signRequest, verifyRequest, generateDeviceKey, isPublicKey, connectCode, sha256Hex } from "./protocol.js";
|
|
11
|
-
export { deviceBlockProblems, renderCommand, resolveOutput, DeviceCommandError, COMMAND_NAME_RE, DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, OUTPUT_LIMITS } from "./commands.js";
|
|
12
|
-
export { commandDigest, commandDigests, newAuthority } from "./digest.js";
|
|
11
|
+
export { deviceBlockProblems, programProblems, renderCommand, resolveOutput, DeviceCommandError, COMMAND_NAME_RE, DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, OUTPUT_LIMITS, PROGRAM_MAX_BYTES, PROGRAM_MAX_FILES } from "./commands.js";
|
|
12
|
+
export { commandDigest, commandDigests, newAuthority, programDigest, approvedCommandNames, PROGRAM_KEY } from "./digest.js";
|
|
13
|
+
export { defineProgram } from "./program-api.js";
|
|
14
|
+
export { createProgramManager, programPaths, programEnv } from "./program-manager.js";
|
|
13
15
|
export { makeClient, DoorError, isTransient } from "./client.js";
|
|
14
16
|
export { connect, startSupervisor } from "./supervisor.js";
|
|
15
17
|
export { deviceHome, machineFingerprint } from "./state.js";
|
|
16
18
|
export { execute, openWorkspace, tokenize, sandboxFor, confinedArgv, AGENT_NAME_RE } from "./shell.js";
|
|
17
19
|
export { parseGrant, grantFromChoice, effectiveGrant, describeGrant } from "./grant.js";
|
|
18
20
|
export { resolveInScope, applyEdits, applyPatch, parseUnifiedDiff, FileError, Journal } from "./files.js";
|
|
19
|
-
export { registry, announce, builtInCapabilities, RUNTIME_CAPABILITIES, commandsCapability, workspaceCapability, OutputUploader } from "./capabilities.js";
|
|
21
|
+
export { registry, announce, builtInCapabilities, RUNTIME_CAPABILITIES, commandsCapability, workspaceCapability, programCapability, OutputUploader } from "./capabilities.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an app's program sees on the computer (computer-apps.md §4.1). Types
|
|
3
|
+
* only: a program imports them with `import type`, which disappears when Node
|
|
4
|
+
* runs the file, so the program needs nothing installed beside it.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // device/main.ts
|
|
8
|
+
* import type { Program } from "esoul-sdk/machine";
|
|
9
|
+
*
|
|
10
|
+
* export default {
|
|
11
|
+
* async start(p) {
|
|
12
|
+
* p.every(60_000, async () => p.op("sample", { cpu: await cpuPercent() }));
|
|
13
|
+
* p.on("sample-now", async () => ({ cpu: await cpuPercent() }));
|
|
14
|
+
* p.status("Sampling every minute", { ready: true });
|
|
15
|
+
* },
|
|
16
|
+
* } satisfies Program;
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
/** The program's handle on the computer and on its app. */
|
|
20
|
+
export interface ProgramApi {
|
|
21
|
+
/** This computer, as the runtime sees it. */
|
|
22
|
+
readonly computer: {
|
|
23
|
+
readonly hostname: string;
|
|
24
|
+
readonly platform: string;
|
|
25
|
+
readonly arch: string;
|
|
26
|
+
readonly cpus: number;
|
|
27
|
+
readonly memoryGb: number;
|
|
28
|
+
};
|
|
29
|
+
/** The app instance this program serves, by its name in the workspace. */
|
|
30
|
+
readonly appName: string | null;
|
|
31
|
+
/** The program's own files (read-only). */
|
|
32
|
+
readonly programDir: string;
|
|
33
|
+
/** A folder the program may write, kept across updates and restarts. */
|
|
34
|
+
readonly dataDir: string;
|
|
35
|
+
/** The folder the owner granted this app on this computer, when there is one. */
|
|
36
|
+
readonly workspaceRoot: string | null;
|
|
37
|
+
/** Values set on this computer with `esoul-device config set`; never sent to ExternalSoul. */
|
|
38
|
+
readonly config: Readonly<Record<string, string>>;
|
|
39
|
+
/**
|
|
40
|
+
* Say what the program is doing, in words, on the app's Computers card
|
|
41
|
+
* ("Sampling every minute"). `ready: true` marks the program ready.
|
|
42
|
+
* Coalesced: at most one report a second leaves the computer.
|
|
43
|
+
*/
|
|
44
|
+
status(text: string, extra?: {
|
|
45
|
+
progress?: number;
|
|
46
|
+
ready?: boolean;
|
|
47
|
+
}): void;
|
|
48
|
+
/**
|
|
49
|
+
* Call one of the app's own ops, as the owner who approved this computer
|
|
50
|
+
* (the op sees `viewer.kind === "agent"` and `viewer.device`). Ops must be
|
|
51
|
+
* idempotent: a lost answer is retried.
|
|
52
|
+
*/
|
|
53
|
+
op<T = unknown>(name: string, args?: unknown, opts?: {
|
|
54
|
+
key?: string;
|
|
55
|
+
}): Promise<T>;
|
|
56
|
+
/** Handle a message the app sends (`devices(ctx).program(linkId).send(topic, data)`); the return value is the answer. */
|
|
57
|
+
on(topic: string, handler: (data: unknown) => unknown | Promise<unknown>): void;
|
|
58
|
+
/** Run `fn` every `ms` (at least 1000); a slow run is never overlapped. Returns a stop function. */
|
|
59
|
+
every(ms: number, fn: () => unknown | Promise<unknown>): () => void;
|
|
60
|
+
/** A line in the program's log on the computer (console.log goes there too). */
|
|
61
|
+
log(...args: unknown[]): void;
|
|
62
|
+
}
|
|
63
|
+
/** An app's program: `start` is called once per process; `stop` before the process ends, when it ends cleanly. */
|
|
64
|
+
export interface Program {
|
|
65
|
+
start(p: ProgramApi): void | Promise<void>;
|
|
66
|
+
stop?(): void | Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
/** Identity helper for a program written in plain JavaScript (TypeScript can use `satisfies Program`). */
|
|
69
|
+
export declare function defineProgram(program: Program): Program;
|
|
70
|
+
/** One setup step, as the Computers card shows it. */
|
|
71
|
+
export interface ProgramStepState {
|
|
72
|
+
id: string;
|
|
73
|
+
describe: string;
|
|
74
|
+
state: "pending" | "running" | "done" | "cached" | "failed";
|
|
75
|
+
startedAt?: number;
|
|
76
|
+
endedAt?: number;
|
|
77
|
+
detail?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Where an app's program stands on one computer — what the runtime reports
|
|
81
|
+
* and the app's Computers card renders. `phase` moves forward
|
|
82
|
+
* installing → setup → self-test → running (or ready, for a program started
|
|
83
|
+
* by its first message); `failed` and `refused` say why in `error`.
|
|
84
|
+
*/
|
|
85
|
+
export interface ProgramState {
|
|
86
|
+
digest: string;
|
|
87
|
+
phase: "installing" | "setup" | "self-test" | "ready" | "starting" | "running" | "stopped" | "failed" | "refused";
|
|
88
|
+
steps: ProgramStepState[];
|
|
89
|
+
/** The program's own words from `p.status`. */
|
|
90
|
+
status?: {
|
|
91
|
+
text: string;
|
|
92
|
+
progress?: number;
|
|
93
|
+
ready?: boolean;
|
|
94
|
+
at: number;
|
|
95
|
+
} | null;
|
|
96
|
+
error?: string | null;
|
|
97
|
+
/** The last lines of the log, when something failed. */
|
|
98
|
+
logTail?: string[];
|
|
99
|
+
restarts: number;
|
|
100
|
+
updatedAt: number;
|
|
101
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an app's program sees on the computer (computer-apps.md §4.1). Types
|
|
3
|
+
* only: a program imports them with `import type`, which disappears when Node
|
|
4
|
+
* runs the file, so the program needs nothing installed beside it.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // device/main.ts
|
|
8
|
+
* import type { Program } from "esoul-sdk/machine";
|
|
9
|
+
*
|
|
10
|
+
* export default {
|
|
11
|
+
* async start(p) {
|
|
12
|
+
* p.every(60_000, async () => p.op("sample", { cpu: await cpuPercent() }));
|
|
13
|
+
* p.on("sample-now", async () => ({ cpu: await cpuPercent() }));
|
|
14
|
+
* p.status("Sampling every minute", { ready: true });
|
|
15
|
+
* },
|
|
16
|
+
* } satisfies Program;
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
/** Identity helper for a program written in plain JavaScript (TypeScript can use `satisfies Program`). */
|
|
20
|
+
export function defineProgram(program) {
|
|
21
|
+
return program;
|
|
22
|
+
}
|