@agen-ai/sandbox 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +141 -2
- package/dist/acp/index.d.ts +9 -0
- package/dist/acp/index.js +25 -0
- package/dist/backend/contracts.d.ts +41 -0
- package/dist/backend/contracts.js +0 -0
- package/dist/backend/index.d.ts +6 -0
- package/dist/backend/index.js +64 -0
- package/dist/backend/spill.d.ts +15 -0
- package/dist/backend/spill.js +0 -0
- package/dist/contracts.d.ts +37 -0
- package/dist/contracts.js +0 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +73 -0
- package/dist/files/bytes.d.ts +31 -0
- package/dist/files/bytes.js +134 -0
- package/dist/files/iterator.d.ts +13 -0
- package/dist/files/iterator.js +59 -0
- package/dist/files/metadata/index.d.ts +12 -0
- package/dist/files/metadata/index.js +193 -0
- package/dist/files/metadata/records.d.ts +21 -0
- package/dist/files/metadata/records.js +50 -0
- package/dist/files/metadata/sort.d.ts +13 -0
- package/dist/files/metadata/sort.js +63 -0
- package/dist/files/metadata/storage.d.ts +12 -0
- package/dist/files/metadata/storage.js +69 -0
- package/dist/files/metadata/types.d.ts +19 -0
- package/dist/files/metadata/types.js +0 -0
- package/dist/files/parse.d.ts +20 -0
- package/dist/files/parse.js +62 -0
- package/dist/files/pathSchemas.d.ts +5 -0
- package/dist/files/pathSchemas.js +16 -0
- package/dist/files/paths.d.ts +4 -0
- package/dist/files/paths.js +12 -0
- package/dist/files/quotas.d.ts +18 -0
- package/dist/files/quotas.js +37 -0
- package/dist/files/types.d.ts +81 -0
- package/dist/files/types.js +0 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +35 -0
- package/dist/internal/data.d.ts +6 -0
- package/dist/internal/data.js +68 -0
- package/dist/internal/operations.d.ts +4 -0
- package/dist/internal/operations.js +30 -0
- package/dist/internal/parse.d.ts +3 -0
- package/dist/internal/parse.js +12 -0
- package/dist/lifecycle/deadline.d.ts +3 -0
- package/dist/lifecycle/deadline.js +18 -0
- package/dist/lifecycle/journal.d.ts +14 -0
- package/dist/lifecycle/journal.js +51 -0
- package/dist/lifecycle/observation.d.ts +9 -0
- package/dist/lifecycle/observation.js +41 -0
- package/dist/lifecycle/owner.d.ts +38 -0
- package/dist/lifecycle/owner.js +288 -0
- package/dist/lifecycle/recovered.d.ts +16 -0
- package/dist/lifecycle/recovered.js +62 -0
- package/dist/lifecycle/recoveryIdentity.d.ts +3 -0
- package/dist/lifecycle/recoveryIdentity.js +18 -0
- package/dist/lifecycle/transfer.d.ts +10 -0
- package/dist/lifecycle/transfer.js +339 -0
- package/dist/node/directories/closing.d.ts +2 -0
- package/dist/node/directories/closing.js +19 -0
- package/dist/node/directories/export.d.ts +6 -0
- package/dist/node/directories/export.js +43 -0
- package/dist/node/directories/file.d.ts +26 -0
- package/dist/node/directories/file.js +104 -0
- package/dist/node/directories/import.d.ts +6 -0
- package/dist/node/directories/import.js +38 -0
- package/dist/node/directories/options.d.ts +4 -0
- package/dist/node/directories/options.js +10 -0
- package/dist/node/directories/ownership.d.ts +33 -0
- package/dist/node/directories/ownership.js +148 -0
- package/dist/node/directories/source.d.ts +25 -0
- package/dist/node/directories/source.js +143 -0
- package/dist/node/directories/stage.d.ts +43 -0
- package/dist/node/directories/stage.js +302 -0
- package/dist/node/directories/types.d.ts +23 -0
- package/dist/node/directories/types.js +17 -0
- package/dist/node/hostPath.d.ts +7 -0
- package/dist/node/hostPath.js +37 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.js +14 -0
- package/dist/node/spill.d.ts +14 -0
- package/dist/node/spill.js +97 -0
- package/dist/node/spillDirectory.d.ts +4 -0
- package/dist/node/spillDirectory.js +17 -0
- package/dist/node/spillFile.d.ts +24 -0
- package/dist/node/spillFile.js +96 -0
- package/dist/policy/limits.d.ts +31 -0
- package/dist/policy/limits.js +35 -0
- package/dist/policy/parse.d.ts +11 -0
- package/dist/policy/parse.js +47 -0
- package/dist/policy/schemas.d.ts +148 -0
- package/dist/policy/schemas.js +75 -0
- package/dist/policy/types.d.ts +105 -0
- package/dist/policy/types.js +0 -0
- package/dist/process/input.d.ts +23 -0
- package/dist/process/input.js +93 -0
- package/dist/process/output.d.ts +13 -0
- package/dist/process/output.js +43 -0
- package/dist/process/parse.d.ts +4 -0
- package/dist/process/parse.js +32 -0
- package/dist/process/streams.d.ts +9 -0
- package/dist/process/streams.js +61 -0
- package/dist/process/types.d.ts +38 -0
- package/dist/process/types.js +0 -0
- package/dist/recovery/parse.d.ts +4 -0
- package/dist/recovery/parse.js +67 -0
- package/dist/recovery/types.d.ts +59 -0
- package/dist/recovery/types.js +0 -0
- package/dist/withSandbox.d.ts +4 -0
- package/dist/withSandbox.js +39 -0
- package/package.json +76 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { SandboxOperationError } from "../errors.js";
|
|
2
|
+
import { SANDBOX_LIMITS } from "../policy/limits.js";
|
|
3
|
+
import { parseTreeMetadata, transferData } from "./parse.js";
|
|
4
|
+
import { TransferIterator } from "./iterator.js";
|
|
5
|
+
const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
6
|
+
const byteLengthGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteLength")?.get;
|
|
7
|
+
const bufferGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, "buffer")?.get;
|
|
8
|
+
const byteOffsetGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteOffset")?.get;
|
|
9
|
+
function actualByteLength(bytes) {
|
|
10
|
+
if (!byteLengthGetter) throw new TypeError("Typed-array byte length is unavailable.");
|
|
11
|
+
return Reflect.apply(byteLengthGetter, bytes, []);
|
|
12
|
+
}
|
|
13
|
+
class TransferBody {
|
|
14
|
+
constructor(byteLength, chunkBytes, requireEof, signal, open, interrupt) {
|
|
15
|
+
this.chunkBytes = chunkBytes;
|
|
16
|
+
this.requireEof = requireEof;
|
|
17
|
+
this.signal = signal;
|
|
18
|
+
this.open = open;
|
|
19
|
+
this.interrupt = interrupt;
|
|
20
|
+
this.remaining = byteLength;
|
|
21
|
+
}
|
|
22
|
+
source;
|
|
23
|
+
used = false;
|
|
24
|
+
retired = false;
|
|
25
|
+
remaining;
|
|
26
|
+
eof = false;
|
|
27
|
+
pending;
|
|
28
|
+
failure;
|
|
29
|
+
get complete() {
|
|
30
|
+
return this.remaining === 0 && (!this.requireEof || this.eof) && !this.pending && !this.failure;
|
|
31
|
+
}
|
|
32
|
+
[Symbol.asyncIterator]() {
|
|
33
|
+
if (this.used || this.retired) return this.rejectIterator("File bodies are single-use and scoped to their entry.");
|
|
34
|
+
this.used = true;
|
|
35
|
+
return { next: () => this.next(), return: async () => {
|
|
36
|
+
await this.close();
|
|
37
|
+
return { done: true, value: void 0 };
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
40
|
+
rejectIterator(message) {
|
|
41
|
+
const error = new SandboxOperationError("driver_failure", message);
|
|
42
|
+
this.failure ??= { error };
|
|
43
|
+
this.interrupt(error);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
next() {
|
|
47
|
+
if (this.pending) {
|
|
48
|
+
try {
|
|
49
|
+
this.rejectIterator("Concurrent file body pulls are forbidden.");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return Promise.reject(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (this.retired) return Promise.reject(new SandboxOperationError("driver_failure", "File body scope has ended."));
|
|
55
|
+
const actual = (async () => {
|
|
56
|
+
this.signal.throwIfAborted();
|
|
57
|
+
if (this.remaining === 0 && (!this.requireEof || this.eof)) return { done: true, value: void 0 };
|
|
58
|
+
const open = this.open;
|
|
59
|
+
if (!open) throw new SandboxOperationError("driver_failure", "File body scope has ended.");
|
|
60
|
+
this.source ??= new TransferIterator(open());
|
|
61
|
+
const result = await this.source.next();
|
|
62
|
+
this.signal.throwIfAborted();
|
|
63
|
+
if (result.done) {
|
|
64
|
+
if (this.remaining !== 0) throw new SandboxOperationError("driver_failure", "File body ended before its declared byteLength.");
|
|
65
|
+
this.eof = true;
|
|
66
|
+
return { done: true, value: void 0 };
|
|
67
|
+
}
|
|
68
|
+
const chunk = result.value;
|
|
69
|
+
if (!(chunk instanceof Uint8Array)) throw new SandboxOperationError("driver_failure", "File bodies require Uint8Array chunks.");
|
|
70
|
+
const length = actualByteLength(chunk);
|
|
71
|
+
if (length > this.chunkBytes || length > this.remaining) throw new SandboxOperationError("driver_failure", "File body chunk exceeds its bounded buffer or declared byteLength.");
|
|
72
|
+
const bytes = new Uint8Array(length);
|
|
73
|
+
Uint8Array.prototype.set.call(bytes, chunk);
|
|
74
|
+
this.remaining -= bytes.byteLength;
|
|
75
|
+
return { done: false, value: bytes };
|
|
76
|
+
})();
|
|
77
|
+
this.pending = actual;
|
|
78
|
+
void actual.then(() => {
|
|
79
|
+
this.pending = void 0;
|
|
80
|
+
}, (error) => {
|
|
81
|
+
this.pending = void 0;
|
|
82
|
+
this.failure ??= { error };
|
|
83
|
+
this.interrupt(error);
|
|
84
|
+
});
|
|
85
|
+
return actual;
|
|
86
|
+
}
|
|
87
|
+
assertComplete() {
|
|
88
|
+
if (this.failure) throw this.failure.error;
|
|
89
|
+
if (!this.complete) throw new SandboxOperationError("driver_failure", "File body was not completely consumed before its owner returned.");
|
|
90
|
+
}
|
|
91
|
+
rejectReuse() {
|
|
92
|
+
return this.rejectIterator("Input file open is once-only.");
|
|
93
|
+
}
|
|
94
|
+
async close(retry = false) {
|
|
95
|
+
this.retired = true;
|
|
96
|
+
await this.pending?.catch(() => {
|
|
97
|
+
});
|
|
98
|
+
await this.source?.close(retry);
|
|
99
|
+
this.source = void 0;
|
|
100
|
+
this.open = void 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function fileFromBytes(path, bytes, options = {}) {
|
|
104
|
+
if (!(bytes instanceof Uint8Array)) throw new SandboxOperationError("input_limit", "fileFromBytes requires a byte array.");
|
|
105
|
+
const selected = transferData(options, ["executable", "chunkBytes"]);
|
|
106
|
+
const chunkBytes = selected.chunkBytes ?? SANDBOX_LIMITS.maxTransferChunkBytes;
|
|
107
|
+
if (typeof chunkBytes !== "number" || !Number.isSafeInteger(chunkBytes) || chunkBytes < 1 || chunkBytes > SANDBOX_LIMITS.maxTransferChunkBytes) throw new SandboxOperationError("input_limit", "fileFromBytes chunkBytes must fit the transfer buffer bound.");
|
|
108
|
+
const byteLength = actualByteLength(bytes);
|
|
109
|
+
if (!bufferGetter || !byteOffsetGetter) throw new TypeError("Typed-array backing storage is unavailable.");
|
|
110
|
+
const buffer = Reflect.apply(bufferGetter, bytes, []);
|
|
111
|
+
const byteOffset = Reflect.apply(byteOffsetGetter, bytes, []);
|
|
112
|
+
const executable = selected.executable ?? false;
|
|
113
|
+
if (typeof executable !== "boolean") throw new SandboxOperationError("input_limit", "fileFromBytes executable must be boolean.");
|
|
114
|
+
const candidate = {
|
|
115
|
+
kind: "file",
|
|
116
|
+
path,
|
|
117
|
+
executable,
|
|
118
|
+
byteLength,
|
|
119
|
+
open({ signal }) {
|
|
120
|
+
return { *[Symbol.iterator]() {
|
|
121
|
+
for (let offset = 0; offset < byteLength; offset += chunkBytes) {
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
yield new Uint8Array(buffer, byteOffset + offset, Math.min(chunkBytes, byteLength - offset));
|
|
124
|
+
}
|
|
125
|
+
} };
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
parseTreeMetadata(candidate, "open");
|
|
129
|
+
return Object.freeze(candidate);
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
TransferBody,
|
|
133
|
+
fileFromBytes
|
|
134
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class TransferIterator<Value> {
|
|
2
|
+
private readonly iterator;
|
|
3
|
+
private pending;
|
|
4
|
+
private closing;
|
|
5
|
+
private closeFailed;
|
|
6
|
+
private ended;
|
|
7
|
+
constructor(source: Iterable<Value> | AsyncIterable<Value>);
|
|
8
|
+
get done(): boolean;
|
|
9
|
+
get pulling(): boolean;
|
|
10
|
+
next(): Promise<IteratorResult<Value>>;
|
|
11
|
+
close(retry?: boolean): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=iterator.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { SandboxOperationError } from "../errors.js";
|
|
2
|
+
class TransferIterator {
|
|
3
|
+
iterator;
|
|
4
|
+
pending;
|
|
5
|
+
closing;
|
|
6
|
+
closeFailed = false;
|
|
7
|
+
ended = false;
|
|
8
|
+
constructor(source) {
|
|
9
|
+
if (source === null || typeof source !== "object" && typeof source !== "function") throw new SandboxOperationError("driver_failure", "Expected an iterable transfer source.");
|
|
10
|
+
const asyncMethod = source[Symbol.asyncIterator];
|
|
11
|
+
const method = asyncMethod ?? source[Symbol.iterator];
|
|
12
|
+
if (typeof method !== "function") throw new SandboxOperationError("driver_failure", "Expected an iterable transfer source.");
|
|
13
|
+
this.iterator = Reflect.apply(method, source, []);
|
|
14
|
+
if (this.iterator === null || typeof this.iterator !== "object" && typeof this.iterator !== "function") throw new SandboxOperationError("driver_failure", "Transfer iterator construction failed.");
|
|
15
|
+
}
|
|
16
|
+
get done() {
|
|
17
|
+
return this.ended;
|
|
18
|
+
}
|
|
19
|
+
get pulling() {
|
|
20
|
+
return this.pending !== void 0;
|
|
21
|
+
}
|
|
22
|
+
next() {
|
|
23
|
+
if (this.pending || this.closing) return Promise.reject(new SandboxOperationError("driver_failure", "Concurrent or retired transfer pulls are forbidden."));
|
|
24
|
+
if (this.ended) return Promise.resolve({ done: true, value: void 0 });
|
|
25
|
+
const actual = Promise.resolve().then(() => this.iterator.next()).then((result) => {
|
|
26
|
+
if (result === null || typeof result !== "object") throw new SandboxOperationError("driver_failure", "Transfer iterator returned an invalid result.");
|
|
27
|
+
if (result.done) {
|
|
28
|
+
this.ended = true;
|
|
29
|
+
return { done: true, value: void 0 };
|
|
30
|
+
}
|
|
31
|
+
return { done: false, value: result.value };
|
|
32
|
+
});
|
|
33
|
+
this.pending = actual;
|
|
34
|
+
void actual.then(() => {
|
|
35
|
+
this.pending = void 0;
|
|
36
|
+
}, () => {
|
|
37
|
+
this.pending = void 0;
|
|
38
|
+
});
|
|
39
|
+
return actual;
|
|
40
|
+
}
|
|
41
|
+
close(retry = false) {
|
|
42
|
+
if (this.closing && (!retry || !this.closeFailed)) return this.closing;
|
|
43
|
+
this.closeFailed = false;
|
|
44
|
+
const closing = (async () => {
|
|
45
|
+
await this.pending?.catch(() => {
|
|
46
|
+
});
|
|
47
|
+
if (!this.ended && this.iterator.return !== void 0) await this.iterator.return();
|
|
48
|
+
this.ended = true;
|
|
49
|
+
})();
|
|
50
|
+
this.closing = closing;
|
|
51
|
+
void closing.catch(() => {
|
|
52
|
+
this.closeFailed = true;
|
|
53
|
+
});
|
|
54
|
+
return closing;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
TransferIterator
|
|
59
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MetadataLedger, MetadataLedgerOptions } from './types.js';
|
|
2
|
+
export type { MetadataLedger, MetadataLedgerOptions, MetadataRecord } from './types.js';
|
|
3
|
+
export { MetadataConflictError } from './records.js';
|
|
4
|
+
export declare class MetadataCleanupError extends Error {
|
|
5
|
+
readonly primaryError: unknown;
|
|
6
|
+
readonly cleanupError: unknown;
|
|
7
|
+
private readonly owner;
|
|
8
|
+
constructor(primaryError: unknown, cleanupError: unknown, owner: MetadataLedger);
|
|
9
|
+
retryCleanup(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function createMetadataLedger(options: MetadataLedgerOptions): Promise<MetadataLedger>;
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { SandboxStateError } from "../../errors.js";
|
|
2
|
+
import { compareRecords, decodeRecord, MAX_RECORD_BYTES, RECORD_HEADER_BYTES } from "./records.js";
|
|
3
|
+
import { encodeRecord } from "./records.js";
|
|
4
|
+
import { mergePass, validateRun } from "./sort.js";
|
|
5
|
+
import { address, readRecord, writeDescriptor, writeRecord } from "./storage.js";
|
|
6
|
+
import { MetadataConflictError } from "./records.js";
|
|
7
|
+
class MetadataCleanupError extends Error {
|
|
8
|
+
constructor(primaryError, cleanupError, owner) {
|
|
9
|
+
super("Metadata spill cleanup remains owned.", { cause: primaryError });
|
|
10
|
+
this.primaryError = primaryError;
|
|
11
|
+
this.cleanupError = cleanupError;
|
|
12
|
+
this.owner = owner;
|
|
13
|
+
this.name = "MetadataCleanupError";
|
|
14
|
+
}
|
|
15
|
+
retryCleanup() {
|
|
16
|
+
return this.owner.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
class Ledger {
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.signal = options.signal ? AbortSignal.any([this.controller.signal, options.signal]) : this.controller.signal;
|
|
23
|
+
this.acquiring = this.acquire();
|
|
24
|
+
}
|
|
25
|
+
phase = "open";
|
|
26
|
+
controller = new AbortController();
|
|
27
|
+
signal;
|
|
28
|
+
acquiring;
|
|
29
|
+
store;
|
|
30
|
+
files;
|
|
31
|
+
active;
|
|
32
|
+
closing;
|
|
33
|
+
batch = [];
|
|
34
|
+
batchSize = 0;
|
|
35
|
+
state = { runs: 0, end: 0, descriptorEnd: 0 };
|
|
36
|
+
reader;
|
|
37
|
+
async acquire() {
|
|
38
|
+
this.signal.throwIfAborted();
|
|
39
|
+
const store = await this.options.openSpill();
|
|
40
|
+
this.store = store;
|
|
41
|
+
this.signal.throwIfAborted();
|
|
42
|
+
const first = await store.createFile();
|
|
43
|
+
this.signal.throwIfAborted();
|
|
44
|
+
const second = await store.createFile();
|
|
45
|
+
this.signal.throwIfAborted();
|
|
46
|
+
const third = await store.createFile();
|
|
47
|
+
this.signal.throwIfAborted();
|
|
48
|
+
const fourth = await store.createFile();
|
|
49
|
+
this.signal.throwIfAborted();
|
|
50
|
+
this.files = { data: [first, second], descriptors: [third, fourth] };
|
|
51
|
+
}
|
|
52
|
+
async initialized() {
|
|
53
|
+
await this.acquiring;
|
|
54
|
+
}
|
|
55
|
+
append(record) {
|
|
56
|
+
if (this.phase !== "open" || this.active) return Promise.reject(new SandboxStateError("Metadata append requires an idle open ledger."));
|
|
57
|
+
const captured = encodeRecord(record);
|
|
58
|
+
return this.enter(async () => {
|
|
59
|
+
const size = RECORD_HEADER_BYTES + captured.key.length;
|
|
60
|
+
if (this.batchSize + size > this.options.batchBytes) await this.flush();
|
|
61
|
+
this.signal.throwIfAborted();
|
|
62
|
+
this.batch.push(captured);
|
|
63
|
+
this.batchSize += size;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finish() {
|
|
67
|
+
if (this.phase !== "open" || this.active) return Promise.reject(new SandboxStateError("Metadata sealing requires an idle open ledger."));
|
|
68
|
+
this.phase = "finishing";
|
|
69
|
+
return this.enter(async () => {
|
|
70
|
+
await this.flush();
|
|
71
|
+
const files = this.readyFiles();
|
|
72
|
+
while (this.state.runs > 1) this.state = await mergePass(files, this.state, this.options.fanIn, this.signal);
|
|
73
|
+
await validateRun(files.data[0], this.state.end, this.signal);
|
|
74
|
+
this.signal.throwIfAborted();
|
|
75
|
+
this.phase = "sealed";
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
records() {
|
|
79
|
+
if (this.phase !== "sealed" || this.active || this.reader) throw new SandboxStateError("Metadata records require a sealed ledger and one exclusive reader.");
|
|
80
|
+
const identity = {};
|
|
81
|
+
this.reader = identity;
|
|
82
|
+
const cursor = { position: 0, end: this.state.end };
|
|
83
|
+
let returned = false;
|
|
84
|
+
let iterated = false;
|
|
85
|
+
let pending;
|
|
86
|
+
const release = () => {
|
|
87
|
+
if (this.reader === identity) this.reader = void 0;
|
|
88
|
+
};
|
|
89
|
+
const iterator = {
|
|
90
|
+
[Symbol.asyncIterator]() {
|
|
91
|
+
if (iterated) throw new SandboxStateError("Metadata records admit one iterator.");
|
|
92
|
+
iterated = true;
|
|
93
|
+
return iterator;
|
|
94
|
+
},
|
|
95
|
+
next: () => {
|
|
96
|
+
if (returned) return Promise.resolve({ done: true, value: void 0 });
|
|
97
|
+
if (this.phase !== "sealed" || this.active) return Promise.reject(new SandboxStateError("Metadata reads require an idle sealed ledger."));
|
|
98
|
+
pending = this.enter(async () => {
|
|
99
|
+
const record = await readRecord(this.readyFiles().data[0], cursor);
|
|
100
|
+
this.signal.throwIfAborted();
|
|
101
|
+
if (!record) {
|
|
102
|
+
returned = true;
|
|
103
|
+
release();
|
|
104
|
+
return { done: true, value: void 0 };
|
|
105
|
+
}
|
|
106
|
+
return { done: false, value: decodeRecord(record) };
|
|
107
|
+
});
|
|
108
|
+
return pending;
|
|
109
|
+
},
|
|
110
|
+
return: async () => {
|
|
111
|
+
returned = true;
|
|
112
|
+
await pending?.catch(() => {
|
|
113
|
+
});
|
|
114
|
+
release();
|
|
115
|
+
return { done: true, value: void 0 };
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
return iterator;
|
|
119
|
+
}
|
|
120
|
+
close() {
|
|
121
|
+
if (this.phase === "closed") return Promise.resolve();
|
|
122
|
+
if (this.closing) return this.closing;
|
|
123
|
+
this.phase = "closing";
|
|
124
|
+
this.controller.abort(new SandboxStateError("Metadata ledger is closing."));
|
|
125
|
+
this.closing = (async () => {
|
|
126
|
+
await this.acquiring.catch(() => {
|
|
127
|
+
});
|
|
128
|
+
await this.active?.catch(() => {
|
|
129
|
+
});
|
|
130
|
+
this.batch = [];
|
|
131
|
+
this.batchSize = 0;
|
|
132
|
+
await this.store?.close();
|
|
133
|
+
this.phase = "closed";
|
|
134
|
+
})().finally(() => {
|
|
135
|
+
this.closing = void 0;
|
|
136
|
+
});
|
|
137
|
+
return this.closing;
|
|
138
|
+
}
|
|
139
|
+
enter(perform) {
|
|
140
|
+
const actual = Promise.resolve().then(() => {
|
|
141
|
+
this.signal.throwIfAborted();
|
|
142
|
+
return perform();
|
|
143
|
+
}).catch((error) => {
|
|
144
|
+
if (this.phase !== "closing" && this.phase !== "closed") this.phase = "failed";
|
|
145
|
+
throw error;
|
|
146
|
+
}).finally(() => {
|
|
147
|
+
if (this.active === actual) this.active = void 0;
|
|
148
|
+
});
|
|
149
|
+
this.active = actual;
|
|
150
|
+
return actual;
|
|
151
|
+
}
|
|
152
|
+
async flush() {
|
|
153
|
+
if (!this.batch.length) return;
|
|
154
|
+
const files = this.readyFiles();
|
|
155
|
+
this.batch.sort(compareRecords);
|
|
156
|
+
let end = this.state.end;
|
|
157
|
+
for (const record of this.batch) {
|
|
158
|
+
this.signal.throwIfAborted();
|
|
159
|
+
end = await writeRecord(files.data[0], record, end);
|
|
160
|
+
}
|
|
161
|
+
const descriptorEnd = await writeDescriptor(files.descriptors[0], this.state.descriptorEnd, this.state.end, end);
|
|
162
|
+
this.state = { runs: address(this.state.runs + 1), end, descriptorEnd };
|
|
163
|
+
this.batch = [];
|
|
164
|
+
this.batchSize = 0;
|
|
165
|
+
}
|
|
166
|
+
readyFiles() {
|
|
167
|
+
if (!this.files) throw new SandboxStateError("Metadata spill acquisition has not settled.");
|
|
168
|
+
return this.files;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function createMetadataLedger(options) {
|
|
172
|
+
if (!Number.isSafeInteger(options.batchBytes) || options.batchBytes < MAX_RECORD_BYTES || options.batchBytes > 4 * 1024 * 1024) {
|
|
173
|
+
throw new RangeError("Metadata batch working set must be between 1029 bytes and 4 MiB.");
|
|
174
|
+
}
|
|
175
|
+
if (!Number.isSafeInteger(options.fanIn) || options.fanIn < 2 || options.fanIn > 32) throw new RangeError("Metadata merge fan-in must be between 2 and 32.");
|
|
176
|
+
const ledger = new Ledger(options);
|
|
177
|
+
try {
|
|
178
|
+
await ledger.initialized();
|
|
179
|
+
return ledger;
|
|
180
|
+
} catch (primaryError) {
|
|
181
|
+
try {
|
|
182
|
+
await ledger.close();
|
|
183
|
+
} catch (cleanupError) {
|
|
184
|
+
throw new MetadataCleanupError(primaryError, cleanupError, ledger);
|
|
185
|
+
}
|
|
186
|
+
throw primaryError;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export {
|
|
190
|
+
MetadataCleanupError,
|
|
191
|
+
MetadataConflictError,
|
|
192
|
+
createMetadataLedger
|
|
193
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { SandboxValidationError } from '../../errors.js';
|
|
3
|
+
import type { MetadataRecord } from './types.js';
|
|
4
|
+
export declare const RECORD_HEADER_BYTES = 5;
|
|
5
|
+
export declare const DESCRIPTOR_BYTES = 16;
|
|
6
|
+
export declare const MAX_RECORD_BYTES: number;
|
|
7
|
+
export interface EncodedRecord {
|
|
8
|
+
readonly kind: MetadataRecord['kind'];
|
|
9
|
+
readonly key: Buffer;
|
|
10
|
+
}
|
|
11
|
+
export declare class MetadataConflictError extends SandboxValidationError {
|
|
12
|
+
readonly code: 'duplicate_path' | 'file_ancestor';
|
|
13
|
+
readonly path: string;
|
|
14
|
+
constructor(code: 'duplicate_path' | 'file_ancestor', path: string);
|
|
15
|
+
}
|
|
16
|
+
export declare function encodeRecord(record: MetadataRecord): EncodedRecord;
|
|
17
|
+
export declare function decodeRecord(record: EncodedRecord): MetadataRecord;
|
|
18
|
+
export declare function compareRecords(left: EncodedRecord, right: EncodedRecord): number;
|
|
19
|
+
export declare function isDescendant(record: EncodedRecord, ancestor: EncodedRecord): boolean;
|
|
20
|
+
export declare function recordHeader(record: EncodedRecord): Buffer;
|
|
21
|
+
//# sourceMappingURL=records.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { SandboxValidationError } from "../../errors.js";
|
|
3
|
+
import { SANDBOX_LIMITS } from "../../policy/limits.js";
|
|
4
|
+
import { parseSandboxRelativePath } from "../paths.js";
|
|
5
|
+
const RECORD_HEADER_BYTES = 5;
|
|
6
|
+
const DESCRIPTOR_BYTES = 16;
|
|
7
|
+
const MAX_RECORD_BYTES = SANDBOX_LIMITS.maxPathBytes + RECORD_HEADER_BYTES;
|
|
8
|
+
class MetadataConflictError extends SandboxValidationError {
|
|
9
|
+
constructor(code, path) {
|
|
10
|
+
super([{ code: "invalid_input", path: ["path"], message: `Metadata ${code}: ${path}` }]);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.name = "MetadataConflictError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function encodeRecord(record) {
|
|
17
|
+
const key = Buffer.from(record.path, "utf8");
|
|
18
|
+
for (let index = 0; index < key.length; index += 1) if (key[index] === 47) key[index] = 0;
|
|
19
|
+
return { kind: record.kind, key };
|
|
20
|
+
}
|
|
21
|
+
function decodeRecord(record) {
|
|
22
|
+
const encoded = record.key.toString("utf8");
|
|
23
|
+
if (record.key.includes(47) || !Buffer.from(encoded, "utf8").equals(record.key)) {
|
|
24
|
+
throw new Error("Spill path encoding is invalid.");
|
|
25
|
+
}
|
|
26
|
+
return Object.freeze({ kind: record.kind, path: parseSandboxRelativePath(encoded.replaceAll("\0", "/")) });
|
|
27
|
+
}
|
|
28
|
+
function compareRecords(left, right) {
|
|
29
|
+
return Buffer.compare(left.key, right.key);
|
|
30
|
+
}
|
|
31
|
+
function isDescendant(record, ancestor) {
|
|
32
|
+
return record.key.length > ancestor.key.length && record.key[ancestor.key.length] === 0 && record.key.subarray(0, ancestor.key.length).equals(ancestor.key);
|
|
33
|
+
}
|
|
34
|
+
function recordHeader(record) {
|
|
35
|
+
const header = Buffer.alloc(RECORD_HEADER_BYTES);
|
|
36
|
+
header.writeUInt32BE(record.key.length);
|
|
37
|
+
header[4] = record.kind === "file" ? 1 : 2;
|
|
38
|
+
return header;
|
|
39
|
+
}
|
|
40
|
+
export {
|
|
41
|
+
DESCRIPTOR_BYTES,
|
|
42
|
+
MAX_RECORD_BYTES,
|
|
43
|
+
MetadataConflictError,
|
|
44
|
+
RECORD_HEADER_BYTES,
|
|
45
|
+
compareRecords,
|
|
46
|
+
decodeRecord,
|
|
47
|
+
encodeRecord,
|
|
48
|
+
isDescendant,
|
|
49
|
+
recordHeader
|
|
50
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SandboxDriverSpillFile } from '../../backend/spill.js';
|
|
2
|
+
export interface SpillFiles {
|
|
3
|
+
data: [SandboxDriverSpillFile, SandboxDriverSpillFile];
|
|
4
|
+
descriptors: [SandboxDriverSpillFile, SandboxDriverSpillFile];
|
|
5
|
+
}
|
|
6
|
+
export interface SortState {
|
|
7
|
+
readonly runs: number;
|
|
8
|
+
readonly end: number;
|
|
9
|
+
readonly descriptorEnd: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function mergePass(files: SpillFiles, input: SortState, fanIn: number, signal: AbortSignal): Promise<SortState>;
|
|
12
|
+
export declare function validateRun(file: SandboxDriverSpillFile, end: number, signal: AbortSignal): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=sort.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { compareRecords, decodeRecord, DESCRIPTOR_BYTES, isDescendant, MetadataConflictError } from "./records.js";
|
|
2
|
+
import { address, readDescriptor, readRecord, writeDescriptor, writeRecord } from "./storage.js";
|
|
3
|
+
async function mergePass(files, input, fanIn, signal) {
|
|
4
|
+
await files.data[1].truncate(0);
|
|
5
|
+
await files.descriptors[1].truncate(0);
|
|
6
|
+
let end = 0;
|
|
7
|
+
let descriptorEnd = 0;
|
|
8
|
+
let runs = 0;
|
|
9
|
+
let index = 0;
|
|
10
|
+
while (index < input.runs) {
|
|
11
|
+
signal.throwIfAborted();
|
|
12
|
+
const heads = [];
|
|
13
|
+
const count = Math.min(fanIn, input.runs - index);
|
|
14
|
+
for (let item = 0; item < count; item += 1) {
|
|
15
|
+
const cursor = await readDescriptor(files.descriptors[0], address(index * DESCRIPTOR_BYTES));
|
|
16
|
+
heads.push({ cursor, record: await readRecord(files.data[0], cursor) });
|
|
17
|
+
index += 1;
|
|
18
|
+
}
|
|
19
|
+
const start = end;
|
|
20
|
+
for (; ; ) {
|
|
21
|
+
signal.throwIfAborted();
|
|
22
|
+
let selected;
|
|
23
|
+
for (const head2 of heads) {
|
|
24
|
+
const record = head2.record;
|
|
25
|
+
if (record && (!selected || compareRecords(record, selected.record) < 0)) selected = { head: head2, record };
|
|
26
|
+
}
|
|
27
|
+
if (!selected) break;
|
|
28
|
+
end = await writeRecord(files.data[1], selected.record, end);
|
|
29
|
+
const head = selected.head;
|
|
30
|
+
head.record = void 0;
|
|
31
|
+
head.record = await readRecord(files.data[0], head.cursor);
|
|
32
|
+
}
|
|
33
|
+
descriptorEnd = await writeDescriptor(files.descriptors[1], descriptorEnd, start, end);
|
|
34
|
+
runs = address(runs + 1);
|
|
35
|
+
}
|
|
36
|
+
files.data = [files.data[1], files.data[0]];
|
|
37
|
+
files.descriptors = [files.descriptors[1], files.descriptors[0]];
|
|
38
|
+
return { runs, end, descriptorEnd };
|
|
39
|
+
}
|
|
40
|
+
async function validateRun(file, end, signal) {
|
|
41
|
+
const cursor = { position: 0, end };
|
|
42
|
+
const ancestors = [];
|
|
43
|
+
for (; ; ) {
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
const record = await readRecord(file, cursor);
|
|
46
|
+
if (!record) break;
|
|
47
|
+
const decoded = decodeRecord(record);
|
|
48
|
+
const previous = ancestors.at(-1);
|
|
49
|
+
if (previous && compareRecords(previous, record) === 0) throw new MetadataConflictError("duplicate_path", decoded.path);
|
|
50
|
+
for (; ; ) {
|
|
51
|
+
const ancestor = ancestors.at(-1);
|
|
52
|
+
if (!ancestor || isDescendant(record, ancestor)) break;
|
|
53
|
+
ancestors.pop();
|
|
54
|
+
}
|
|
55
|
+
if (ancestors.at(-1)?.kind === "file") throw new MetadataConflictError("file_ancestor", decoded.path);
|
|
56
|
+
ancestors.push(record);
|
|
57
|
+
}
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
}
|
|
60
|
+
export {
|
|
61
|
+
mergePass,
|
|
62
|
+
validateRun
|
|
63
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SandboxDriverSpillFile } from '../../backend/spill.js';
|
|
2
|
+
import type { EncodedRecord } from './records.js';
|
|
3
|
+
export interface RunCursor {
|
|
4
|
+
position: number;
|
|
5
|
+
readonly end: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function address(value: number): number;
|
|
8
|
+
export declare function writeRecord(file: SandboxDriverSpillFile, record: EncodedRecord, position: number): Promise<number>;
|
|
9
|
+
export declare function readRecord(file: SandboxDriverSpillFile, cursor: RunCursor): Promise<EncodedRecord | undefined>;
|
|
10
|
+
export declare function writeDescriptor(file: SandboxDriverSpillFile, position: number, start: number, end: number): Promise<number>;
|
|
11
|
+
export declare function readDescriptor(file: SandboxDriverSpillFile, position: number): Promise<RunCursor>;
|
|
12
|
+
//# sourceMappingURL=storage.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { SANDBOX_LIMITS } from "../../policy/limits.js";
|
|
3
|
+
import { DESCRIPTOR_BYTES, RECORD_HEADER_BYTES, recordHeader } from "./records.js";
|
|
4
|
+
function address(value) {
|
|
5
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError("Spill address exceeds its safe-integer representation.");
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
async function writeAll(file, bytes, position) {
|
|
9
|
+
address(position + bytes.length);
|
|
10
|
+
let written = 0;
|
|
11
|
+
while (written < bytes.length) {
|
|
12
|
+
const remaining = bytes.length - written;
|
|
13
|
+
const { bytesWritten } = await file.write(bytes, written, remaining, position + written);
|
|
14
|
+
if (!Number.isSafeInteger(bytesWritten) || bytesWritten <= 0 || bytesWritten > remaining) {
|
|
15
|
+
throw new Error("Spill write returned an invalid progress receipt.");
|
|
16
|
+
}
|
|
17
|
+
written += bytesWritten;
|
|
18
|
+
}
|
|
19
|
+
return position + written;
|
|
20
|
+
}
|
|
21
|
+
async function readExact(file, length, position) {
|
|
22
|
+
address(position + length);
|
|
23
|
+
const buffer = Buffer.alloc(length);
|
|
24
|
+
let read = 0;
|
|
25
|
+
while (read < length) {
|
|
26
|
+
const remaining = length - read;
|
|
27
|
+
const { bytesRead } = await file.read(buffer, read, remaining, position + read);
|
|
28
|
+
if (!Number.isSafeInteger(bytesRead) || bytesRead <= 0 || bytesRead > remaining) {
|
|
29
|
+
throw new Error("Spill read returned truncated data or an invalid progress receipt.");
|
|
30
|
+
}
|
|
31
|
+
read += bytesRead;
|
|
32
|
+
}
|
|
33
|
+
return buffer;
|
|
34
|
+
}
|
|
35
|
+
async function writeRecord(file, record, position) {
|
|
36
|
+
return writeAll(file, record.key, await writeAll(file, recordHeader(record), position));
|
|
37
|
+
}
|
|
38
|
+
async function readRecord(file, cursor) {
|
|
39
|
+
if (cursor.position === cursor.end) return void 0;
|
|
40
|
+
if (cursor.end - cursor.position < RECORD_HEADER_BYTES) throw new Error("Spill record header is truncated.");
|
|
41
|
+
const header = await readExact(file, RECORD_HEADER_BYTES, cursor.position);
|
|
42
|
+
const length = header.readUInt32BE(0);
|
|
43
|
+
if (length < 1 || length > SANDBOX_LIMITS.maxPathBytes || header[4] !== 1 && header[4] !== 2 || cursor.end - cursor.position - RECORD_HEADER_BYTES < length) throw new Error("Spill record metadata is invalid.");
|
|
44
|
+
const key = await readExact(file, length, cursor.position + RECORD_HEADER_BYTES);
|
|
45
|
+
cursor.position = address(cursor.position + RECORD_HEADER_BYTES + length);
|
|
46
|
+
return { kind: header[4] === 1 ? "file" : "directory", key };
|
|
47
|
+
}
|
|
48
|
+
async function writeDescriptor(file, position, start, end) {
|
|
49
|
+
const buffer = Buffer.alloc(DESCRIPTOR_BYTES);
|
|
50
|
+
buffer.writeBigUInt64BE(BigInt(address(start)));
|
|
51
|
+
buffer.writeBigUInt64BE(BigInt(address(end)), 8);
|
|
52
|
+
return writeAll(file, buffer, position);
|
|
53
|
+
}
|
|
54
|
+
async function readDescriptor(file, position) {
|
|
55
|
+
const buffer = await readExact(file, DESCRIPTOR_BYTES, position);
|
|
56
|
+
const first = buffer.readBigUInt64BE(0);
|
|
57
|
+
const last = buffer.readBigUInt64BE(8);
|
|
58
|
+
if (first > BigInt(Number.MAX_SAFE_INTEGER) || last > BigInt(Number.MAX_SAFE_INTEGER) || last <= first) {
|
|
59
|
+
throw new Error("Spill run descriptor exceeds its safe address representation.");
|
|
60
|
+
}
|
|
61
|
+
return { position: Number(first), end: Number(last) };
|
|
62
|
+
}
|
|
63
|
+
export {
|
|
64
|
+
address,
|
|
65
|
+
readDescriptor,
|
|
66
|
+
readRecord,
|
|
67
|
+
writeDescriptor,
|
|
68
|
+
writeRecord
|
|
69
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SandboxDriverSpillStore } from '../../backend/spill.js';
|
|
2
|
+
import type { SandboxRelativePath } from '../types.js';
|
|
3
|
+
export interface MetadataRecord {
|
|
4
|
+
readonly path: SandboxRelativePath;
|
|
5
|
+
readonly kind: 'file' | 'directory';
|
|
6
|
+
}
|
|
7
|
+
export interface MetadataLedgerOptions {
|
|
8
|
+
readonly openSpill: () => Promise<SandboxDriverSpillStore>;
|
|
9
|
+
readonly batchBytes: number;
|
|
10
|
+
readonly fanIn: number;
|
|
11
|
+
readonly signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
export interface MetadataLedger {
|
|
14
|
+
append(record: MetadataRecord): Promise<void>;
|
|
15
|
+
finish(): Promise<void>;
|
|
16
|
+
records(): AsyncIterable<MetadataRecord>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=types.d.ts.map
|
|
File without changes
|