@parcha/pi-durable-disk 0.1.0-beta.21 → 0.1.0-beta.26
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 +33 -1
- package/README.md +12 -8
- package/dist/claim.d.ts +1 -1
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +63 -18
- package/dist/env.js +3 -0
- package/dist/hosts/attempt-env.d.ts +9 -0
- package/dist/hosts/attempt-env.js +12 -1
- package/dist/hosts/daytona.d.ts +1 -1
- package/dist/hosts/daytona.js +2 -0
- package/dist/hosts/docker.d.ts +1 -1
- package/dist/hosts/docker.js +1 -0
- package/dist/hosts/local-host.d.ts +1 -1
- package/dist/hosts/local-host.js +119 -26
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/place.d.ts +64 -1
- package/dist/place.js +133 -4
- package/dist/run-files.d.ts +72 -0
- package/dist/run-files.js +259 -0
- package/dist/supervise.d.ts +12 -0
- package/package.json +3 -3
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// A run's own files, read over the disk's control (S3 GET, HEAD and LIST) and nothing else: these calls never mount, never
|
|
2
|
+
// write, and never take or wait on the run's claim, so anyone may read a run another host holds. A read sees what the
|
|
3
|
+
// holder has synced (its last barrier), and a file the holder writes whole (a temp file, then a link or a rename) is never
|
|
4
|
+
// seen half-written. What a file means is the reader's business: disk parses none of them. The one write here,
|
|
5
|
+
// `putRunFile`, creates a file in a run nobody holds (a held run refuses it), for a place's start window.
|
|
6
|
+
//
|
|
7
|
+
// How Archil's S3 API answers, measured on archil 0.8.42: a missing key is 404; a directory is a marker key ending in "/";
|
|
8
|
+
// a symlink is listed as an ordinary zero-byte key, but its HEAD and GET answer 501 NotImplemented, and S3 never follows a
|
|
9
|
+
// link. So a symlink is told on a read's 501, never from a listing.
|
|
10
|
+
import { constants } from "node:fs";
|
|
11
|
+
import { lstat, open, readdir, stat } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { runPath } from "./claim.js";
|
|
14
|
+
import { PdaError } from "./errors.js";
|
|
15
|
+
/** A read of a run's files that cannot be answered. Its message names the path, never what a file holds. */
|
|
16
|
+
export class RunFileError extends PdaError {
|
|
17
|
+
constructor(code, message, options = {}) {
|
|
18
|
+
super(code, message, options);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What is wrong with `path` as a relative path of safe segments inside the run, or null when nothing is. The answer names
|
|
23
|
+
* the fault only, never the path, which may hold anything a caller passed.
|
|
24
|
+
*/
|
|
25
|
+
function pathFault(path, allowRoot) {
|
|
26
|
+
if (path === "")
|
|
27
|
+
return allowRoot ? null : "it is empty";
|
|
28
|
+
if (path.startsWith("/"))
|
|
29
|
+
return "it is absolute";
|
|
30
|
+
const unsafe = path.search(/[^A-Za-z0-9._/-]/);
|
|
31
|
+
if (unsafe >= 0)
|
|
32
|
+
return `it has an unsafe character at index ${unsafe}`;
|
|
33
|
+
const segments = path.split("/");
|
|
34
|
+
if (segments.includes(""))
|
|
35
|
+
return "it has an empty segment";
|
|
36
|
+
if (segments.includes("."))
|
|
37
|
+
return 'it has a "." segment';
|
|
38
|
+
if (segments.includes(".."))
|
|
39
|
+
return 'it has a ".." segment';
|
|
40
|
+
if (segments[0] === ".archil")
|
|
41
|
+
return "it is under .archil/";
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** The segments of `path`, or INVALID_PATH naming what is wrong (never the path). `""` is the run's root where allowed. */
|
|
45
|
+
export function runFileSegments(path, allowRoot) {
|
|
46
|
+
const fault = pathFault(path, allowRoot);
|
|
47
|
+
if (fault !== null)
|
|
48
|
+
throw new RunFileError("INVALID_PATH", `not a relative path of safe segments (letters, digits, ".", "_", "-") inside the run: ${fault}`);
|
|
49
|
+
return path === "" ? [] : path.split("/");
|
|
50
|
+
}
|
|
51
|
+
/** The run's key prefix and `path` under it, or INVALID_PATH. */
|
|
52
|
+
function keyOf(ref, path, allowRoot) {
|
|
53
|
+
const run = `${runPath(ref.id)}/`;
|
|
54
|
+
const segments = runFileSegments(path, allowRoot);
|
|
55
|
+
return { run, key: segments.length === 0 ? run : `${run}${segments.join("/")}` };
|
|
56
|
+
}
|
|
57
|
+
const statusOf = (error) => error?.status ?? error?.statusCode;
|
|
58
|
+
const missing = (error) => statusOf(error) === 404 || error?.code === "NoSuchKey";
|
|
59
|
+
const notImplemented = (error) => statusOf(error) === 501 || error?.code === "NotImplemented";
|
|
60
|
+
/** `call`, ended at once when `signal` aborts (the request itself may still finish; nothing waits on it). */
|
|
61
|
+
function bounded(call, signal) {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
// A control that throws before it returns a promise rejects like any other, so the listener below is always removed.
|
|
64
|
+
const pending = Promise.resolve().then(call);
|
|
65
|
+
if (!signal)
|
|
66
|
+
return pending;
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const onAbort = () => reject(signal.reason);
|
|
69
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
pending.then((v) => (signal.removeEventListener("abort", onAbort), resolve(v)), (e) => (signal.removeEventListener("abort", onAbort), reject(e)));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** A control call; any failure but the ones the caller handles is CONTROL_API_FAILED, an abort stays the caller's. */
|
|
74
|
+
async function ask(what, call, signal) {
|
|
75
|
+
try {
|
|
76
|
+
return await bounded(call, signal);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (signal?.aborted && error === signal.reason)
|
|
80
|
+
throw error;
|
|
81
|
+
throw error instanceof RunFileError ? error : new RunFileError("CONTROL_API_FAILED", `${what} over the control failed`, { cause: error });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function runMissing(control, run, ref, signal) {
|
|
85
|
+
if ((await ask(`reading ${run}`, () => control.headObject(run), signal)) === null)
|
|
86
|
+
throw new RunFileError("RUN_MISSING", `there is no run ${ref.id} on this disk`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The bytes of `path` in the run (`runs/<id>/<path>`), or null when there is no such file yet, so a poller waits. A
|
|
90
|
+
* directory or a symlink is NOT_A_FILE; a run that does not exist is RUN_MISSING. No polling here: the caller bounds its
|
|
91
|
+
* own wait, with `signal` and its own retries.
|
|
92
|
+
*/
|
|
93
|
+
export async function readRunFile(control, ref, path, options = {}) {
|
|
94
|
+
const { signal } = options;
|
|
95
|
+
const { run, key } = keyOf(ref, path, false);
|
|
96
|
+
signal?.throwIfAborted();
|
|
97
|
+
try {
|
|
98
|
+
return await bounded(() => control.getObject(key), signal);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (signal?.aborted && error === signal.reason)
|
|
102
|
+
throw error;
|
|
103
|
+
if (notImplemented(error))
|
|
104
|
+
throw new RunFileError("NOT_A_FILE", `${path} in run ${ref.id} is not a regular file (a symlink: the S3 API reads only files)`, { cause: error });
|
|
105
|
+
if (!missing(error))
|
|
106
|
+
throw new RunFileError("CONTROL_API_FAILED", `reading ${path} in run ${ref.id} over the control failed`, { cause: error });
|
|
107
|
+
}
|
|
108
|
+
// Not there: a directory of that name, no run at all, or a file not written yet.
|
|
109
|
+
if ((await ask(`reading ${path}/`, () => control.headObject(`${key}/`), signal)) !== null)
|
|
110
|
+
throw new RunFileError("NOT_A_FILE", `${path} in run ${ref.id} is a directory`);
|
|
111
|
+
await runMissing(control, run, ref, signal);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The names directly under `dir` in the run (`""` for the run's root), sorted: files and directories alike, names only,
|
|
116
|
+
* never a claim about what each one is. A directory not made yet lists nothing; a run that does not exist is RUN_MISSING.
|
|
117
|
+
*/
|
|
118
|
+
export async function listRunFiles(control, ref, dir, options = {}) {
|
|
119
|
+
const { signal } = options;
|
|
120
|
+
const { run, key } = keyOf(ref, dir, true);
|
|
121
|
+
const prefix = key.endsWith("/") ? key : `${key}/`;
|
|
122
|
+
const listed = await ask(`listing ${dir || "the run"} of run ${ref.id}`, () => control.listObjects(prefix, { recursive: false }), signal);
|
|
123
|
+
const names = new Set();
|
|
124
|
+
for (const k of [...listed.objects.map((o) => o.key), ...(listed.commonPrefixes ?? [])]) {
|
|
125
|
+
if (!k.startsWith(prefix))
|
|
126
|
+
continue;
|
|
127
|
+
const name = k.slice(prefix.length).replace(/\/$/, "");
|
|
128
|
+
if (name !== "" && !name.includes("/"))
|
|
129
|
+
names.add(name);
|
|
130
|
+
}
|
|
131
|
+
if (names.size === 0)
|
|
132
|
+
await runMissing(control, run, ref, signal);
|
|
133
|
+
return [...names].sort();
|
|
134
|
+
}
|
|
135
|
+
/** A put Archil refused because a delegation holds the run: exactly 409 OperationAborted, as D2 measured on archil 0.8.42. */
|
|
136
|
+
const windowClosed = (error) => statusOf(error) === 409 && error?.code === "OperationAborted";
|
|
137
|
+
/**
|
|
138
|
+
* Create `path` in the run (`runs/<id>/<path>`) holding `text`, owned by `owner` with mode 0644, after making each missing
|
|
139
|
+
* parent directory as a marker `owner` owns, mode 0755 (a directory made by path on a mount refuses later creates).
|
|
140
|
+
* Create-only: a file or a directory already at `path` is EXISTS and nothing is written. The check is a HEAD before the
|
|
141
|
+
* PutObject, not a conditional create (Archil's client has none), so it is not exclusive against another writer at the
|
|
142
|
+
* same moment: claim a shared name where a single writer is certain. Only a run nobody holds takes the write; a held one
|
|
143
|
+
* answers 409 OperationAborted, which is WINDOW_CLOSED. For non-secret run files: a put file is readable in the run.
|
|
144
|
+
*/
|
|
145
|
+
export async function putRunFile(control, ref, path, text, owner, options = {}) {
|
|
146
|
+
const { signal } = options;
|
|
147
|
+
const segments = runFileSegments(path, false);
|
|
148
|
+
const run = `${runPath(ref.id)}/`;
|
|
149
|
+
const key = `${run}${segments.join("/")}`;
|
|
150
|
+
const call = async (what, fn) => {
|
|
151
|
+
try {
|
|
152
|
+
return await bounded(fn, signal);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (signal?.aborted && error === signal.reason)
|
|
156
|
+
throw error;
|
|
157
|
+
if (windowClosed(error))
|
|
158
|
+
throw new RunFileError("WINDOW_CLOSED", `${what} was refused: a delegation holds run ${ref.id} (409 OperationAborted)`, { cause: error });
|
|
159
|
+
throw new RunFileError("CONTROL_API_FAILED", `${what} over the control failed`, { cause: error });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
if ((await call(`reading ${path}`, () => control.headObject(key))) !== null || (await call(`reading ${path}/`, () => control.headObject(`${key}/`))) !== null) {
|
|
163
|
+
throw new RunFileError("EXISTS", `${path} is already in run ${ref.id}: a put creates, never replaces`);
|
|
164
|
+
}
|
|
165
|
+
for (let i = 1; i < segments.length; i++) {
|
|
166
|
+
const dir = segments.slice(0, i).join("/");
|
|
167
|
+
if ((await call(`reading ${dir}/`, () => control.headObject(`${run}${dir}/`))) === null) {
|
|
168
|
+
await call(`making ${dir}/`, () => control.putObject(`${run}${dir}/`, "", { uid: owner.uid, gid: owner.gid, mode: 0o755 }));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
await call(`writing ${path}`, () => control.putObject(key, text, { uid: owner.uid, gid: owner.gid, mode: 0o644 }));
|
|
172
|
+
}
|
|
173
|
+
// ---- the same reads over a run directory on this machine (localPlace) -------------------------------------------------
|
|
174
|
+
const fsCode = (error) => error?.code;
|
|
175
|
+
async function localRunMissing(root, id) {
|
|
176
|
+
try {
|
|
177
|
+
if ((await stat(root)).isDirectory())
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (fsCode(error) !== "ENOENT" && fsCode(error) !== "ENOTDIR")
|
|
182
|
+
throw new RunFileError("CONTROL_API_FAILED", `reading run ${id} failed`, { cause: error });
|
|
183
|
+
}
|
|
184
|
+
throw new RunFileError("RUN_MISSING", `there is no run ${id} on this machine`);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Walk `segments` under `root` with lstat: a symlink anywhere on the way is NOT_A_FILE (never followed, so nothing outside
|
|
188
|
+
* the run is ever read). Resolves with the last path and what it is, or null when something on the way is not there.
|
|
189
|
+
*/
|
|
190
|
+
async function walk(root, id, path, segments) {
|
|
191
|
+
let at = root;
|
|
192
|
+
for (const [i, segment] of segments.entries()) {
|
|
193
|
+
at = join(at, segment);
|
|
194
|
+
let st;
|
|
195
|
+
try {
|
|
196
|
+
st = await lstat(at);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (fsCode(error) === "ENOENT" || fsCode(error) === "ENOTDIR")
|
|
200
|
+
return null;
|
|
201
|
+
throw new RunFileError("CONTROL_API_FAILED", `reading ${path} in run ${id} failed`, { cause: error });
|
|
202
|
+
}
|
|
203
|
+
if (st.isSymbolicLink())
|
|
204
|
+
throw new RunFileError("NOT_A_FILE", `${path} in run ${id} goes through a symlink, which is never followed`);
|
|
205
|
+
if (i < segments.length - 1 && !st.isDirectory())
|
|
206
|
+
return null;
|
|
207
|
+
if (i === segments.length - 1)
|
|
208
|
+
return { at, dir: st.isDirectory() };
|
|
209
|
+
}
|
|
210
|
+
return { at, dir: true };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* `readRunFile` over the run directory `root` on this machine: the same path rules and answers. A directory, or a symlink
|
|
214
|
+
* anywhere on the way, is NOT_A_FILE, and a file swapped for a link after the check is refused, not followed.
|
|
215
|
+
*/
|
|
216
|
+
export async function readLocalRunFile(root, id, path) {
|
|
217
|
+
const segments = runFileSegments(path, false);
|
|
218
|
+
await localRunMissing(root, id);
|
|
219
|
+
const found = await walk(root, id, path, segments);
|
|
220
|
+
if (!found)
|
|
221
|
+
return null;
|
|
222
|
+
if (found.dir)
|
|
223
|
+
throw new RunFileError("NOT_A_FILE", `${path} in run ${id} is a directory`);
|
|
224
|
+
let handle;
|
|
225
|
+
try {
|
|
226
|
+
handle = await open(found.at, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
227
|
+
if (!(await handle.stat()).isFile())
|
|
228
|
+
throw new RunFileError("NOT_A_FILE", `${path} in run ${id} is not a regular file`);
|
|
229
|
+
return new Uint8Array(await handle.readFile());
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error instanceof RunFileError)
|
|
233
|
+
throw error;
|
|
234
|
+
if (fsCode(error) === "ELOOP")
|
|
235
|
+
throw new RunFileError("NOT_A_FILE", `${path} in run ${id} is a symlink, which is never followed`);
|
|
236
|
+
if (fsCode(error) === "ENOENT")
|
|
237
|
+
return null;
|
|
238
|
+
throw new RunFileError("CONTROL_API_FAILED", `reading ${path} in run ${id} failed`, { cause: error });
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
await handle?.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** `listRunFiles` over the run directory `root` on this machine: the same rules; a symlink on the way is NOT_A_FILE. */
|
|
245
|
+
export async function listLocalRunFiles(root, id, dir) {
|
|
246
|
+
const segments = runFileSegments(dir, true);
|
|
247
|
+
await localRunMissing(root, id);
|
|
248
|
+
const found = await walk(root, id, dir, segments);
|
|
249
|
+
if (!found || !found.dir)
|
|
250
|
+
return [];
|
|
251
|
+
try {
|
|
252
|
+
return (await readdir(found.at)).sort();
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (fsCode(error) === "ENOENT" || fsCode(error) === "ENOTDIR")
|
|
256
|
+
return [];
|
|
257
|
+
throw new RunFileError("CONTROL_API_FAILED", `listing ${dir || "the run"} of run ${id} failed`, { cause: error });
|
|
258
|
+
}
|
|
259
|
+
}
|
package/dist/supervise.d.ts
CHANGED
|
@@ -59,6 +59,18 @@ export interface HostDriver {
|
|
|
59
59
|
readonly kills?: "process" | "box";
|
|
60
60
|
/** Optional, for a driver whose instances get machines of their own: the box `handle`'s instance runs in, as a receipt. */
|
|
61
61
|
box?(handle: HostHandle): HostBox | null;
|
|
62
|
+
/**
|
|
63
|
+
* Optional, with `box`: the provider of the boxes this driver makes (its receipts' `provider`), known before a box
|
|
64
|
+
* exists. A place tells each host box's runner which label to claim by it (`HOST_BOX`).
|
|
65
|
+
*/
|
|
66
|
+
readonly provider?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Optional: the name of the instance `handle` is for, the same for its fresh handle and an adopted one (dockerHost: the
|
|
69
|
+
* daemon and the container's name), or `undefined` for a handle that is not this driver's or names none. A place keys
|
|
70
|
+
* the boxes it started by it, so a box adopted again keeps its key. Two handles are one instance only when this names
|
|
71
|
+
* them both, equally: without it no two handles ever are.
|
|
72
|
+
*/
|
|
73
|
+
instanceOf?(handle: HostHandle): string | undefined;
|
|
62
74
|
}
|
|
63
75
|
/** The control-plane calls the supervisor uses; an archil `Disk` satisfies it. */
|
|
64
76
|
export interface SupervisorControl extends ControlApi {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parcha/pi-durable-disk",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0-beta.26",
|
|
4
|
+
"description": "The agent is the workspace: its conversation and its files live together in one directory on a disk. Mount it on any Linux host or container, release it, and mount it elsewhere: the agent carries on.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-durable",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@earendil-works/pi-durable": ">=1.0.4 <1.4.0"
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"disk": "1.
|
|
75
|
+
"disk": "1.9.0"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"@earendil-works/chord": "1.1.0",
|