@earendil-works/pi-env 0.0.0-stage → 1.0.4
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 +8 -0
- package/README.md +43 -2
- package/bin/pi-env-android-arm64/pi-env +0 -0
- package/bin/pi-env-android-x64/pi-env +0 -0
- package/bin/pi-env-darwin-arm64/pi-env +0 -0
- package/bin/pi-env-darwin-x64/pi-env +0 -0
- package/bin/pi-env-linux-arm64/pi-env +0 -0
- package/bin/pi-env-linux-x64/pi-env +0 -0
- package/bin/pi-env-windows-arm64/pi-env.exe +0 -0
- package/bin/pi-env-windows-x64/pi-env.exe +0 -0
- package/dist/connection.d.ts +74 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +321 -0
- package/dist/connection.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +34 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/remote-env.d.ts +67 -0
- package/dist/remote-env.d.ts.map +1 -0
- package/dist/remote-env.js +838 -0
- package/dist/remote-env.js.map +1 -0
- package/dist/ssh.d.ts +103 -0
- package/dist/ssh.d.ts.map +1 -0
- package/dist/ssh.js +441 -0
- package/dist/ssh.js.map +1 -0
- package/dist/watch.d.ts +25 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +134 -0
- package/dist/watch.js.map +1 -0
- package/docs/protocol.md +114 -0
- package/docs/semantics.md +65 -0
- package/package.json +54 -5
package/dist/watch.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { FileError } from "@earendil-works/pi-durable/env";
|
|
2
|
+
import { isConnectionLost } from "./connection.js";
|
|
3
|
+
import { toFileError } from "./errors.js";
|
|
4
|
+
const RECONNECT_FIRST_MS = 1000;
|
|
5
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
6
|
+
/**
|
|
7
|
+
* A watcher running in the daemon (Durable's `NodeFileWatcher` next to the files). When the connection is lost, it is
|
|
8
|
+
* opened again once the connection can be started again, and reports `overflow`: changes in between were not seen.
|
|
9
|
+
*/
|
|
10
|
+
export class RemoteWatcher {
|
|
11
|
+
#connection;
|
|
12
|
+
#targets;
|
|
13
|
+
#onChange;
|
|
14
|
+
#options;
|
|
15
|
+
#mode = "native";
|
|
16
|
+
#closed = false;
|
|
17
|
+
#controller;
|
|
18
|
+
#request;
|
|
19
|
+
#wake;
|
|
20
|
+
constructor(connection, targets, onChange, options) {
|
|
21
|
+
this.#connection = connection;
|
|
22
|
+
this.#targets = targets;
|
|
23
|
+
this.#onChange = onChange;
|
|
24
|
+
this.#options = options;
|
|
25
|
+
}
|
|
26
|
+
/** Watch `targets` (resolved paths); fails like `NodeFileWatcher.open` if coverage cannot be established. */
|
|
27
|
+
static async open(connection, targets, onChange, options = {}) {
|
|
28
|
+
const watcher = new RemoteWatcher(connection, targets, onChange, options);
|
|
29
|
+
await watcher.#start();
|
|
30
|
+
return watcher;
|
|
31
|
+
}
|
|
32
|
+
get mode() {
|
|
33
|
+
return this.#mode;
|
|
34
|
+
}
|
|
35
|
+
async close(_context) {
|
|
36
|
+
if (this.#closed)
|
|
37
|
+
return;
|
|
38
|
+
this.#closed = true;
|
|
39
|
+
this.#wake?.();
|
|
40
|
+
this.#controller?.abort();
|
|
41
|
+
await this.#request;
|
|
42
|
+
}
|
|
43
|
+
#deliver(change) {
|
|
44
|
+
if (this.#closed)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
this.#onChange(change);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// A throwing callback must not stop watching.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Open the daemon's watcher; resolves once it reports coverage. */
|
|
54
|
+
#start() {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
let ready = false;
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
this.#controller = controller;
|
|
59
|
+
const request = this.#connection.request("watch", {
|
|
60
|
+
targets: this.#targets.map((target) => ({
|
|
61
|
+
path: target.path,
|
|
62
|
+
recursive: target.recursive === true,
|
|
63
|
+
exclude: { hidden: target.exclude?.hidden === true, names: [...(target.exclude?.names ?? [])] },
|
|
64
|
+
})),
|
|
65
|
+
...this.#options,
|
|
66
|
+
}, {
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
onEvent: (event) => {
|
|
69
|
+
if (event.kind === "ready") {
|
|
70
|
+
ready = true;
|
|
71
|
+
this.#mode = event.mode === "polling" ? "polling" : "native";
|
|
72
|
+
resolve();
|
|
73
|
+
}
|
|
74
|
+
else if (event.kind === "change") {
|
|
75
|
+
if (event.mode === "polling")
|
|
76
|
+
this.#mode = "polling";
|
|
77
|
+
if (event.overflow === true)
|
|
78
|
+
this.#deliver({ overflow: true });
|
|
79
|
+
else
|
|
80
|
+
this.#deliver({ paths: event.paths });
|
|
81
|
+
}
|
|
82
|
+
else if (event.kind === "error") {
|
|
83
|
+
const code = event.code === "permission_denied" ? "permission_denied" : "invalid";
|
|
84
|
+
this.#deliver({ error: new FileError(code, String(event.message)) });
|
|
85
|
+
this.#closed = true;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
this.#request = request.then(() => { }, (error) => {
|
|
90
|
+
if (!ready) {
|
|
91
|
+
reject(error);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (this.#closed)
|
|
95
|
+
return;
|
|
96
|
+
if (isConnectionLost(error)) {
|
|
97
|
+
void this.#reconnect();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.#deliver({ error: toFileError(error) });
|
|
101
|
+
this.#closed = true;
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async #reconnect() {
|
|
106
|
+
for (let delay = RECONNECT_FIRST_MS; !this.#closed; delay = Math.min(delay * 2, RECONNECT_MAX_MS)) {
|
|
107
|
+
await new Promise((resolve) => {
|
|
108
|
+
const timer = setTimeout(resolve, delay);
|
|
109
|
+
timer.unref();
|
|
110
|
+
this.#wake = () => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve();
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
this.#wake = undefined;
|
|
116
|
+
if (this.#closed)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
await this.#start();
|
|
120
|
+
// Changes while disconnected were not seen.
|
|
121
|
+
this.#deliver({ overflow: true });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (isConnectionLost(error))
|
|
126
|
+
continue;
|
|
127
|
+
this.#deliver({ error: toFileError(error) });
|
|
128
|
+
this.#closed = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=watch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watch.js","sourceRoot":"","sources":["../src/watch.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Context } from \"@earendil-works/chord\";\nimport { FileError, type FileWatcher, type WatchChange, type WatchTarget } from \"@earendil-works/pi-durable/env\";\nimport { type Connection, isConnectionLost } from \"./connection.ts\";\nimport { toFileError } from \"./errors.ts\";\n\n/** How the daemon watches, like `NodeExecutionEnv`'s `NodeWatchOptions`. */\nexport interface RemoteWatchOptions {\n\t/** Force a mode; by default Windows and file systems that do not report remote changes poll. */\n\tmode?: \"native\" | \"polling\";\n\t/** Interval between snapshots in `polling` mode; default 2000 ms. */\n\tpollIntervalMs?: number;\n\t/** Most directories one watcher covers; default 10,000. */\n\tmaxDirectories?: number;\n}\n\nconst RECONNECT_FIRST_MS = 1000;\nconst RECONNECT_MAX_MS = 30_000;\n\n/**\n * A watcher running in the daemon (Durable's `NodeFileWatcher` next to the files). When the connection is lost, it is\n * opened again once the connection can be started again, and reports `overflow`: changes in between were not seen.\n */\nexport class RemoteWatcher implements FileWatcher {\n\treadonly #connection: Connection;\n\treadonly #targets: readonly WatchTarget[];\n\treadonly #onChange: (change: WatchChange) => void;\n\treadonly #options: RemoteWatchOptions;\n\t#mode: \"native\" | \"polling\" = \"native\";\n\t#closed = false;\n\t#controller: AbortController | undefined;\n\t#request: Promise<void> | undefined;\n\t#wake: (() => void) | undefined;\n\n\tprivate constructor(\n\t\tconnection: Connection,\n\t\ttargets: readonly WatchTarget[],\n\t\tonChange: (change: WatchChange) => void,\n\t\toptions: RemoteWatchOptions,\n\t) {\n\t\tthis.#connection = connection;\n\t\tthis.#targets = targets;\n\t\tthis.#onChange = onChange;\n\t\tthis.#options = options;\n\t}\n\n\t/** Watch `targets` (resolved paths); fails like `NodeFileWatcher.open` if coverage cannot be established. */\n\tstatic async open(\n\t\tconnection: Connection,\n\t\ttargets: readonly WatchTarget[],\n\t\tonChange: (change: WatchChange) => void,\n\t\toptions: RemoteWatchOptions = {},\n\t): Promise<RemoteWatcher> {\n\t\tconst watcher = new RemoteWatcher(connection, targets, onChange, options);\n\t\tawait watcher.#start();\n\t\treturn watcher;\n\t}\n\n\tget mode(): \"native\" | \"polling\" {\n\t\treturn this.#mode;\n\t}\n\n\tasync close(_context: Context): Promise<void> {\n\t\tif (this.#closed) return;\n\t\tthis.#closed = true;\n\t\tthis.#wake?.();\n\t\tthis.#controller?.abort();\n\t\tawait this.#request;\n\t}\n\n\t#deliver(change: WatchChange): void {\n\t\tif (this.#closed) return;\n\t\ttry {\n\t\t\tthis.#onChange(change);\n\t\t} catch {\n\t\t\t// A throwing callback must not stop watching.\n\t\t}\n\t}\n\n\t/** Open the daemon's watcher; resolves once it reports coverage. */\n\t#start(): Promise<void> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tlet ready = false;\n\t\t\tconst controller = new AbortController();\n\t\t\tthis.#controller = controller;\n\t\t\tconst request = this.#connection.request(\n\t\t\t\t\"watch\",\n\t\t\t\t{\n\t\t\t\t\ttargets: this.#targets.map((target) => ({\n\t\t\t\t\t\tpath: target.path,\n\t\t\t\t\t\trecursive: target.recursive === true,\n\t\t\t\t\t\texclude: { hidden: target.exclude?.hidden === true, names: [...(target.exclude?.names ?? [])] },\n\t\t\t\t\t})),\n\t\t\t\t\t...this.#options,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t\tonEvent: (event) => {\n\t\t\t\t\t\tif (event.kind === \"ready\") {\n\t\t\t\t\t\t\tready = true;\n\t\t\t\t\t\t\tthis.#mode = event.mode === \"polling\" ? \"polling\" : \"native\";\n\t\t\t\t\t\t\tresolve();\n\t\t\t\t\t\t} else if (event.kind === \"change\") {\n\t\t\t\t\t\t\tif (event.mode === \"polling\") this.#mode = \"polling\";\n\t\t\t\t\t\t\tif (event.overflow === true) this.#deliver({ overflow: true });\n\t\t\t\t\t\t\telse this.#deliver({ paths: event.paths as string[] });\n\t\t\t\t\t\t} else if (event.kind === \"error\") {\n\t\t\t\t\t\t\tconst code = event.code === \"permission_denied\" ? \"permission_denied\" : \"invalid\";\n\t\t\t\t\t\t\tthis.#deliver({ error: new FileError(code, String(event.message)) });\n\t\t\t\t\t\t\tthis.#closed = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t\tthis.#request = request.then(\n\t\t\t\t() => {},\n\t\t\t\t(error: unknown) => {\n\t\t\t\t\tif (!ready) {\n\t\t\t\t\t\treject(error);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (this.#closed) return;\n\t\t\t\t\tif (isConnectionLost(error)) {\n\t\t\t\t\t\tvoid this.#reconnect();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.#deliver({ error: toFileError(error) });\n\t\t\t\t\tthis.#closed = true;\n\t\t\t\t},\n\t\t\t);\n\t\t});\n\t}\n\n\tasync #reconnect(): Promise<void> {\n\t\tfor (let delay = RECONNECT_FIRST_MS; !this.#closed; delay = Math.min(delay * 2, RECONNECT_MAX_MS)) {\n\t\t\tawait new Promise<void>((resolve) => {\n\t\t\t\tconst timer = setTimeout(resolve, delay);\n\t\t\t\ttimer.unref();\n\t\t\t\tthis.#wake = () => {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\tresolve();\n\t\t\t\t};\n\t\t\t});\n\t\t\tthis.#wake = undefined;\n\t\t\tif (this.#closed) return;\n\t\t\ttry {\n\t\t\t\tawait this.#start();\n\t\t\t\t// Changes while disconnected were not seen.\n\t\t\t\tthis.#deliver({ overflow: true });\n\t\t\t\treturn;\n\t\t\t} catch (error) {\n\t\t\t\tif (isConnectionLost(error)) continue;\n\t\t\t\tthis.#deliver({ error: toFileError(error) });\n\t\t\t\tthis.#closed = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
|
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# pi-env wire protocol
|
|
2
|
+
|
|
3
|
+
Version 1. The client starts `pi-env serve --token <hex>` (over `ssh`, or directly in tests) and talks to it over the
|
|
4
|
+
process's stdin and stdout. stderr is diagnostic text for logs only.
|
|
5
|
+
|
|
6
|
+
## Sync
|
|
7
|
+
|
|
8
|
+
Shell startup files may print to stdout before the daemon runs. The daemon's first output is the line
|
|
9
|
+
`PI-ENV <token>\n`, with the token from its command line. The client discards everything before that line. After it,
|
|
10
|
+
stdout carries only frames.
|
|
11
|
+
|
|
12
|
+
## Frames
|
|
13
|
+
|
|
14
|
+
Every frame, in both directions:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
u32 length bytes after this field
|
|
18
|
+
u8 type
|
|
19
|
+
u32 id request id; 0 for frames that belong to no request
|
|
20
|
+
u32 jsonLength
|
|
21
|
+
json UTF-8 JSON object, jsonLength bytes
|
|
22
|
+
bytes raw payload, the remaining length - 9 - jsonLength bytes
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
All integers are big-endian. A frame is at most 16 MiB; larger transfers are split by the client. A request whose JSON
|
|
26
|
+
does not parse gets an `EINVAL` error; framing errors end the connection.
|
|
27
|
+
|
|
28
|
+
| type | name | direction | meaning |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| 1 | request | client → daemon | `json.op` names the operation |
|
|
31
|
+
| 2 | result | daemon → client | success of request `id`; the request is finished |
|
|
32
|
+
| 3 | error | daemon → client | failure of request `id`; the request is finished |
|
|
33
|
+
| 4 | event | daemon → client | progress of a running request (`exec` output, `watch` changes), not finishing it |
|
|
34
|
+
| 5 | cancel | client → daemon | cancel request `id`; see below |
|
|
35
|
+
| 6 | ping | both | liveness; the receiver ignores it |
|
|
36
|
+
|
|
37
|
+
Errors are `{ code, message, syscall?, path? }`. `code` is a Node-style error code (`ENOENT`, `EISDIR`,
|
|
38
|
+
`ERR_FS_EISDIR`, ...) or one of the execution codes `shell_unavailable`, `spawn_error`, `timeout`, `aborted`, `unknown`.
|
|
39
|
+
Messages are diagnostic; only codes are part of the contract.
|
|
40
|
+
|
|
41
|
+
Paths are absolute JSON strings; the client resolves relative paths, `~` and `file://` URLs before sending. Strings are
|
|
42
|
+
sent well-formed (lone surrogates become U+FFFD) and encoded as UTF-8, as Node encodes string paths and arguments. File
|
|
43
|
+
contents and output travel as raw payload bytes.
|
|
44
|
+
|
|
45
|
+
## Cancellation
|
|
46
|
+
|
|
47
|
+
A cancel names one request. The daemon registers each request before running it, so a cancel sent right after its
|
|
48
|
+
request is never lost. `exec` kills the command's process group and finishes with `aborted`; `{ mode: "kill" }` kills
|
|
49
|
+
without aborting, so the command finishes with the killed process's status, as `cleanup()` does. `scanLines` stops
|
|
50
|
+
between chunks with `aborted`. `watch` stops and finishes with `{}`. Other operations are short and finish normally.
|
|
51
|
+
|
|
52
|
+
## Scheduling
|
|
53
|
+
|
|
54
|
+
File operations run on a fixed pool of worker threads; `exec` and `watch` run on their own threads. Chunk writes to one
|
|
55
|
+
handle run in arrival order. Results, errors, pings and `watch` events are sent before queued command output, and
|
|
56
|
+
results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not replies. Output of a
|
|
57
|
+
command without an output window waits while more than 4 MiB of output is unsent, which slows the command as a full
|
|
58
|
+
pipe would.
|
|
59
|
+
|
|
60
|
+
## Liveness
|
|
61
|
+
|
|
62
|
+
Each side sends `ping` every 5 seconds. The daemon kills every process group it started and exits after 30 seconds
|
|
63
|
+
without receiving any bytes, or when stdin ends. A dropped connection therefore stops remote commands. The client
|
|
64
|
+
fails requests of a lost connection with `{ code: "unknown", lost: true }` and starts a new daemon on the next request;
|
|
65
|
+
handles belong to the daemon that opened them, so requests on them fail instead of reaching the new one.
|
|
66
|
+
|
|
67
|
+
## Operations
|
|
68
|
+
|
|
69
|
+
`hello { protocol }` → `{ protocol, version, os, arch, home, tmpdir, cwd, driveCwds, pid }`. The first request.
|
|
70
|
+
`version` is the npm package version the daemon shipped with; `os` and `arch` are Rust's `std::env::consts` values;
|
|
71
|
+
`tmpdir` follows the remote Node's `os.tmpdir()` rules; `cwd` and `driveCwds` (Windows' `=C:` variables) are what
|
|
72
|
+
Node's `path.resolve` falls back to for drive-relative paths.
|
|
73
|
+
|
|
74
|
+
File operations take `{ path }` (and the listed fields) and return `{}` unless noted:
|
|
75
|
+
|
|
76
|
+
| op | fields | result |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `lstat` | | `info` |
|
|
79
|
+
| `realpath` | | `{ path }` |
|
|
80
|
+
| `write` | `append`, `parents?` (default true), `keep?`, payload | creates missing parents like Node's recursive `mkdir`, opens like `writeFile` (`w`) or `appendFile` (`a`), writes the payload; with `keep`, `{ handle }` for `writeChunk` |
|
|
81
|
+
| `writeChunk` | `handle`, payload | appends to a kept write handle; after a failed chunk, later chunks fail with `EBADF` |
|
|
82
|
+
| `truncate` | `size` | |
|
|
83
|
+
| `fsync` | | |
|
|
84
|
+
| `rename` | `to` | |
|
|
85
|
+
| `mkdir` | `recursive` | |
|
|
86
|
+
| `rm` | `recursive`, `force` | Node `fs.rm` semantics |
|
|
87
|
+
| `mkdtemp` | (`path` is the prefix) | `{ path }` |
|
|
88
|
+
| `open` | `noFollow`, `mode?` | `{ handle, info }` for `openBinaryReader`: nonblocking, regular files only. With `mode: "read"`, Node's `open(path, "r")` of any file, blocking: `{ handle, stat }` or `{ handle, statError }` |
|
|
89
|
+
| `pread` | `handle`, `offset?`, `length` | payload; without `offset`, one read at the current position |
|
|
90
|
+
| `fstat` | `handle` | `info` |
|
|
91
|
+
| `scanLines` | `handle`, `startLine`, `endLine?` | `LineScan` |
|
|
92
|
+
| `opendir` | | `{ handle }` |
|
|
93
|
+
| `readdir` | `handle`, `max` | `{ entries, done }` |
|
|
94
|
+
| `close` | `handle` | |
|
|
95
|
+
|
|
96
|
+
`info` is `{ name, kind, size, mtimeSec, mtimeNsec, dev, ino }` with `kind` one of `file`, `directory`, `symlink`, `other`.
|
|
97
|
+
`readdir` entries are `{ name, raw?, info }` or `{ name, raw?, error }` when the entry's `lstat` failed. Names are
|
|
98
|
+
decoded as UTF-8 with replacement characters, and lstat'ed under that name, as Node does; `raw` holds the original
|
|
99
|
+
bytes of a name that is not valid UTF-8, for sorting. Handles are numbers, valid until `close` or the end of the
|
|
100
|
+
connection; opening more than 4096 fails with `EMFILE`.
|
|
101
|
+
|
|
102
|
+
`exec { command | argv, cwd, env, inheritEnv, shellPath?, timeoutMs?, spill?, window? }` runs a shell string or an
|
|
103
|
+
argv array. While it runs, `event` frames `{ kind: "output", stream, skipped? }` carry decoded output text as UTF-8
|
|
104
|
+
payload. With `window { maxBytes, maxLines, minIntervalMs, bytesPerSecond }`, output is coalesced and paced like the
|
|
105
|
+
caller's commits, at most one output frame per command is unsent, and output followed by more than the window is
|
|
106
|
+
replaced by `skipped { bytes, newlines, endsWithNewline }` on the next event, which carries all output after it
|
|
107
|
+
(`ShellOutputInfo.skipped`). The result is `{ exitCode, spillPath? }`; failures are errors with codes `timeout` or
|
|
108
|
+
`aborted` (carrying `spillPath`), `shell_unavailable`, `spawn_error`, or `unknown` (spill failure).
|
|
109
|
+
|
|
110
|
+
`watch { targets, mode?, pollIntervalMs?, maxDirectories? }` runs Durable's `NodeFileWatcher` in the daemon. `targets`
|
|
111
|
+
are `{ path, recursive, exclude: { hidden, names } }`. Once coverage is established, an event `{ kind: "ready", mode }`
|
|
112
|
+
follows; then `{ kind: "change", paths }` or `{ kind: "change", overflow: true, mode: "polling" }` (native watching
|
|
113
|
+
failed), or `{ kind: "error", code, message }`, after which nothing follows. The request lasts until cancelled.
|
|
114
|
+
Failing to establish coverage is an error of the request.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Semantics
|
|
2
|
+
|
|
3
|
+
The reference is Durable's `NodeExecutionEnv` (`packages/durable/src/env/node.ts`) on the remote machine. Durable's
|
|
4
|
+
env conformance suite and `test/differential.test.ts` check each rule below.
|
|
5
|
+
|
|
6
|
+
## Split of work
|
|
7
|
+
|
|
8
|
+
| Concern | Where |
|
|
9
|
+
|---|---|
|
|
10
|
+
| Path resolution: `~` (remote home), `file://`, relative to `cwd`; on Windows drive-relative paths against the remote's working directories | client, the remote system's `path` rules |
|
|
11
|
+
| Abort checkpoints of each method | client, as `NodeExecutionEnv` |
|
|
12
|
+
| Error code mapping (`ENOENT` → `not_found`, ...) | client, as `NodeExecutionEnv`'s `toFileError` |
|
|
13
|
+
| `readFile`'s read sizes, size limit and decoding (`StringDecoder`, byte-order mark kept) over the daemon's `open(path, "r")` | client |
|
|
14
|
+
| `writeFile` in 512 KiB writes with an abort check before each; `appendFile` without checks | client, over one open file |
|
|
15
|
+
| Line reader | client, positional reads and Durable's `StreamDecoder` |
|
|
16
|
+
| Watching: snapshots, native events, polling | daemon, a port of Durable's `NodeFileWatcher` |
|
|
17
|
+
| Timeout validation, result precedence (callback error, timeout, abort, spill failure, exit code) | client |
|
|
18
|
+
| System calls, processes, spill files, line scans | daemon |
|
|
19
|
+
| Command output decoding: per stream, WHATWG UTF-8, only a leading byte-order mark dropped | daemon (`encoding_rs`) |
|
|
20
|
+
|
|
21
|
+
## Rules the standard libraries do not follow
|
|
22
|
+
|
|
23
|
+
- Recursive `mkdir`: an existing directory is fine, an existing file fails with `EEXIST`, a file in the way of a parent
|
|
24
|
+
fails with `ENOTDIR`. Writes create missing parents this way.
|
|
25
|
+
- `rm`: a missing path fails unless `force`; a directory without `recursive` fails with `ERR_FS_EISDIR`
|
|
26
|
+
(`FileError` code `unknown`).
|
|
27
|
+
- `mkdtemp`: `mkdtemp(3)` of `<tmpdir>/<prefix>XXXXXX`; the prefix is joined with POSIX `path.join`, so `../x` works.
|
|
28
|
+
- `tmpdir`: `TMPDIR`, `TMP`, `TEMP`, then `/tmp` (Termux: `$PREFIX/tmp`), trailing slash removed.
|
|
29
|
+
- `listDir`: names in byte order, like libuv's `scandir`; fails if any entry cannot be lstat'ed. `openDirReader` keeps
|
|
30
|
+
directory order, skips entries removed meanwhile, and fills each page after skipping. Names that are not UTF-8 are
|
|
31
|
+
decoded with replacement characters and lstat'ed under that name, so they fail with `ENOENT` as in Node.
|
|
32
|
+
- `openBinaryReader`: nonblocking open; directories fail with `is_directory`, other non-regular files with `invalid`;
|
|
33
|
+
`noFollow` refuses a final symbolic link (`O_NOFOLLOW`).
|
|
34
|
+
- `readBinaryFile`, `readTextFile` and `openTextLineReader` open any file like `open(path, "r")`: `/dev/null` reads
|
|
35
|
+
empty, a FIFO blocks until it has a writer, and a directory fails when read (a line reader at `readLine`). A regular
|
|
36
|
+
file is read up to its size at open; other files to their end.
|
|
37
|
+
- `readBinaryFile` returns a `Buffer`, reader reads a plain `Uint8Array`, as Node does.
|
|
38
|
+
- Windows: error codes are libuv's translations; `rm` of a symbolic link or junction to a directory removes the link;
|
|
39
|
+
creating a file where a directory exists fails with `EISDIR`; modification times before 1970 are negative.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
- A string runs through the shell: a configured `shellPath` that does not exist (following links) fails with
|
|
44
|
+
`shell_unavailable`; otherwise `/bin/bash`, `which bash`, then `sh`, with `-c`. An argv array runs its program
|
|
45
|
+
directly; on Windows it is searched like libuv does (working directory, then `PATH`, adding `.com` and `.exe`), and
|
|
46
|
+
batch files are refused.
|
|
47
|
+
- The working directory must exist (`spawn_error`).
|
|
48
|
+
- Environment: with `inheritEnv` (default), the daemon's environment, then `shellEnv`, then `env`; without it, only
|
|
49
|
+
`env`.
|
|
50
|
+
- Each command gets a new session (process group), default signal dispositions and an empty signal mask. Timeout,
|
|
51
|
+
abort and `cleanup()` kill the group with `SIGKILL`.
|
|
52
|
+
- After the process exits, output is still collected until both pipes end, or 100 ms pass without output; the timeout
|
|
53
|
+
still applies meanwhile. The pipes are closed when the command settles, even if a descendant keeps them open.
|
|
54
|
+
- An abort settles as `aborted`; `cleanup()` kills without aborting, so the command settles with exit code 137.
|
|
55
|
+
- A process killed by a signal reports `128 + signal`.
|
|
56
|
+
- Spill: once the output crosses `spill.afterBytes` or `spill.afterLines` (counted over raw chunks in arrival order, as
|
|
57
|
+
Node counts), the complete raw output goes to `pi-output-<uuid>.log` in a fresh `tmp-` directory.
|
|
58
|
+
|
|
59
|
+
## Watching
|
|
60
|
+
|
|
61
|
+
- The daemon runs Durable's `NodeFileWatcher` next to the files: native events (inotify, FSEvents) only trigger a
|
|
62
|
+
debounced rescan, and changes are the difference between snapshots plus the event paths. Reads are not events.
|
|
63
|
+
- Windows and network or FUSE file systems poll, as `NodeExecutionEnv` does; running out of native watches switches to
|
|
64
|
+
polling and reports `overflow`.
|
|
65
|
+
- When the connection is lost, the client opens the watcher again once a new daemon starts and reports `overflow`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@earendil-works/pi-env",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "Remote execution environments for Pi Durable: a small daemon deployed over SSH and its ExecutionEnv client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"source": "./src/index.ts",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"bin",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md",
|
|
22
|
+
"CHANGELOG.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"clean": "shx rm -rf dist",
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"build:daemon": "cargo build --manifest-path daemon/Cargo.toml --locked",
|
|
28
|
+
"test": "vitest --run",
|
|
29
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"durable-execution",
|
|
33
|
+
"agents",
|
|
34
|
+
"ssh",
|
|
35
|
+
"remote"
|
|
36
|
+
],
|
|
37
|
+
"author": "Earendil Works",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/earendil-works/pi.git",
|
|
42
|
+
"directory": "packages/env"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22.19.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@earendil-works/chord": "^1.0.4",
|
|
49
|
+
"@earendil-works/pi-durable": "^1.0.4"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"shx": "0.4.0",
|
|
53
|
+
"vitest": "4.1.11"
|
|
54
|
+
}
|
|
55
|
+
}
|