@nimbus-sh/core 0.6.0 → 0.7.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/README.md +47 -26
- package/dist/_shared/byte-stream.d.ts +5 -4
- package/dist/_shared/byte-stream.d.ts.map +1 -1
- package/dist/_shared/real-node-imports.d.ts +7 -0
- package/dist/_shared/real-node-imports.d.ts.map +1 -1
- package/dist/_shared/real-node-imports.js +8 -0
- package/dist/_shared/tarball.d.ts +23 -5
- package/dist/_shared/tarball.d.ts.map +1 -1
- package/dist/_shared/tarball.js +38 -5
- package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
- package/dist/_shared/vfs-write-ledger.js +22 -16
- package/dist/runtime/comment-strip.d.ts +0 -35
- package/dist/runtime/comment-strip.d.ts.map +1 -1
- package/dist/runtime/comment-strip.js +48 -20
- package/dist/runtime/esbuild-service.d.ts +38 -42
- package/dist/runtime/esbuild-service.d.ts.map +1 -1
- package/dist/runtime/esbuild-service.js +494 -197
- package/dist/runtime/javascript-ast.d.ts.map +1 -1
- package/dist/runtime/javascript-ast.js +45 -7
- package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
- package/dist/runtime/opentui-wasm-backend.js +18 -16
- package/dist/runtime/port-registry.d.ts +4 -2
- package/dist/runtime/port-registry.d.ts.map +1 -1
- package/dist/runtime/port-registry.js +60 -8
- package/dist/shell/stdin-adapter.d.ts +11 -0
- package/dist/shell/stdin-adapter.d.ts.map +1 -0
- package/dist/shell/stdin-adapter.js +59 -0
- package/dist/shell/unix-commands.d.ts +2 -1
- package/dist/shell/unix-commands.d.ts.map +1 -1
- package/dist/shell/unix-commands.js +1078 -50
- package/dist/substrate/lifo/commands/io/dd.js +1 -1
- package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/net/curl.js +270 -54
- package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
- package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/system/npm.js +26 -43
- package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/text/head.js +2 -1
- package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/text/sed.js +499 -117
- package/dist/substrate/lifo/commands/types.d.ts +8 -4
- package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
- package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
- package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
- package/dist/substrate/lifo/node-compat/zlib.js +122 -23
- package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
- package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/interpreter.js +79 -29
- package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
- package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/pipe.js +62 -18
- package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
- package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
- package/dist/vfs/sqlite-vfs.d.ts +19 -0
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
- package/dist/vfs/sqlite-vfs.js +106 -23
- package/dist/workspace/nimbus-workspace.d.ts +15 -0
- package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
- package/dist/workspace/nimbus-workspace.js +20 -3
- package/dist/workspace/supervisor-op.d.ts +23 -0
- package/dist/workspace/supervisor-op.d.ts.map +1 -0
- package/dist/workspace/supervisor-op.js +92 -0
- package/package.json +2 -2
- package/src/_shared/byte-stream.ts +5 -4
- package/src/_shared/real-node-imports.ts +8 -0
- package/src/_shared/tarball.ts +56 -5
- package/src/_shared/vfs-write-ledger.ts +22 -16
- package/src/runtime/comment-strip.ts +42 -23
- package/src/runtime/esbuild-service.ts +566 -217
- package/src/runtime/javascript-ast.ts +40 -9
- package/src/runtime/opentui-wasm-backend.ts +19 -17
- package/src/runtime/port-registry.ts +66 -8
- package/src/shell/stdin-adapter.ts +58 -0
- package/src/shell/unix-commands.ts +1054 -50
- package/src/substrate/lifo/commands/io/dd.ts +2 -2
- package/src/substrate/lifo/commands/net/curl.ts +310 -58
- package/src/substrate/lifo/commands/system/npm.ts +58 -38
- package/src/substrate/lifo/commands/text/head.ts +2 -1
- package/src/substrate/lifo/commands/text/sed.ts +519 -109
- package/src/substrate/lifo/commands/types.ts +8 -5
- package/src/substrate/lifo/node-compat/zlib.ts +155 -26
- package/src/substrate/lifo/shell/interpreter.ts +74 -27
- package/src/substrate/lifo/shell/pipe.ts +65 -24
- package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
- package/src/vfs/sqlite-vfs.ts +104 -23
- package/src/workspace/nimbus-workspace.ts +26 -2
- package/src/workspace/supervisor-op.ts +117 -0
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { encode } from '../utils/encoding.js';
|
|
1
2
|
import type { TerminalInputStream } from '../commands/types.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* A bridge between terminal keyboard input and command stdin.
|
|
5
|
-
*
|
|
5
|
+
* A bridge between terminal keyboard input and command stdin. The Shell
|
|
6
|
+
* feeds lines in via feed(); commands consume bytes or text. Bytes are the
|
|
7
|
+
* storage format, so a byte read always makes progress even when maxLength
|
|
8
|
+
* splits a multi-byte code point — `dd bs=1` over `é` yields c3, then a9.
|
|
6
9
|
*/
|
|
7
10
|
export class TerminalStdin implements TerminalInputStream {
|
|
8
|
-
private buffer:
|
|
11
|
+
private buffer: Uint8Array[] = [];
|
|
9
12
|
private closed = false;
|
|
10
|
-
private resolvers: Array<(value:
|
|
13
|
+
private resolvers: Array<(value: Uint8Array | null) => void> = [];
|
|
11
14
|
private _rawMode = false;
|
|
15
|
+
private decoder = new TextDecoder('utf-8');
|
|
12
16
|
|
|
13
17
|
/** True when a command has called read() and is waiting for input. */
|
|
14
18
|
get isWaiting(): boolean {
|
|
@@ -26,16 +30,13 @@ export class TerminalStdin implements TerminalInputStream {
|
|
|
26
30
|
|
|
27
31
|
/** Shell calls this on Enter (with line + '\n'). */
|
|
28
32
|
feed(text: string): void {
|
|
29
|
-
if (this.closed) return;
|
|
30
|
-
|
|
31
|
-
this.deliver(text, 'back');
|
|
33
|
+
if (this.closed || text === '') return;
|
|
34
|
+
this.deliver(encode(text), 'back');
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/** Shell calls this on Ctrl+D to signal EOF. */
|
|
35
38
|
close(): void {
|
|
36
|
-
if (this.closed) return;
|
|
37
39
|
this.closed = true;
|
|
38
|
-
|
|
39
40
|
while (this.resolvers.length > 0) {
|
|
40
41
|
const resolve = this.resolvers.shift()!;
|
|
41
42
|
resolve(null);
|
|
@@ -44,15 +45,15 @@ export class TerminalStdin implements TerminalInputStream {
|
|
|
44
45
|
|
|
45
46
|
/** Commands consume input. Returns null on EOF. */
|
|
46
47
|
async read(): Promise<string | null> {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
while (true) {
|
|
49
|
+
const bytes = await this.pull();
|
|
50
|
+
if (bytes === null) {
|
|
51
|
+
const tail = this.decoder.decode();
|
|
52
|
+
return tail.length > 0 ? tail : null;
|
|
53
|
+
}
|
|
54
|
+
const text = this.decoder.decode(bytes, { stream: true });
|
|
55
|
+
if (text.length > 0) return text;
|
|
52
56
|
}
|
|
53
|
-
return new Promise<string | null>((resolve) => {
|
|
54
|
-
this.resolvers.push(resolve);
|
|
55
|
-
});
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
async readLine(): Promise<string | null> {
|
|
@@ -64,7 +65,7 @@ export class TerminalStdin implements TerminalInputStream {
|
|
|
64
65
|
const newline = chunk.indexOf('\n');
|
|
65
66
|
if (newline >= 0) {
|
|
66
67
|
const rest = chunk.slice(newline + 1);
|
|
67
|
-
if (rest.length > 0) this.
|
|
68
|
+
if (rest.length > 0) this.deliverBackText(rest);
|
|
68
69
|
return line + chunk.slice(0, newline);
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -72,15 +73,21 @@ export class TerminalStdin implements TerminalInputStream {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Bounded byte read: returns whatever the user has already typed, capped
|
|
78
|
+
* at maxLength. maxLength bounds the result, it is never a fill target —
|
|
79
|
+
* a command reading bytes must not stall until maxLength arrive. A larger
|
|
80
|
+
* queued chunk keeps only its first maxLength bytes; the remainder stays
|
|
81
|
+
* queued, so `dd bs=1` over `é` still yields c3, then a9.
|
|
82
|
+
*/
|
|
83
|
+
async readBytes(maxLength: number): Promise<Uint8Array | null> {
|
|
84
|
+
if (maxLength <= 0) return new Uint8Array(0);
|
|
85
|
+
|
|
86
|
+
const chunk = await this.pull();
|
|
79
87
|
if (chunk === null) return null;
|
|
80
88
|
if (chunk.length <= maxLength) return chunk;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return chunk.slice(0, maxLength);
|
|
89
|
+
this.buffer.unshift(chunk.subarray(maxLength));
|
|
90
|
+
return chunk.subarray(0, maxLength);
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/** Read all remaining input until EOF, joined together. */
|
|
@@ -94,16 +101,45 @@ export class TerminalStdin implements TerminalInputStream {
|
|
|
94
101
|
return parts.join('');
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Text snapshot of everything queued but not yet consumed. The shell's
|
|
106
|
+
* wrap layer uses this for commands that want drained terminal input.
|
|
107
|
+
*/
|
|
108
|
+
drainBuffered(): string {
|
|
109
|
+
let text = '';
|
|
110
|
+
while (this.buffer.length > 0) {
|
|
111
|
+
text += this.decoder.decode(this.buffer.shift()!, { stream: true });
|
|
112
|
+
}
|
|
113
|
+
text += this.decoder.decode();
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private pull(): Promise<Uint8Array | null> {
|
|
118
|
+
if (this.buffer.length > 0) {
|
|
119
|
+
return Promise.resolve(this.buffer.shift() ?? null);
|
|
120
|
+
}
|
|
121
|
+
if (this.closed) {
|
|
122
|
+
return Promise.resolve(null);
|
|
123
|
+
}
|
|
124
|
+
return new Promise<Uint8Array | null>((resolve) => {
|
|
125
|
+
this.resolvers.push(resolve);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private deliver(bytes: Uint8Array, position: 'front' | 'back'): void {
|
|
130
|
+
if (bytes.length === 0) return;
|
|
99
131
|
|
|
100
132
|
if (this.resolvers.length > 0) {
|
|
101
133
|
const resolve = this.resolvers.shift()!;
|
|
102
|
-
resolve(
|
|
134
|
+
resolve(bytes);
|
|
103
135
|
return;
|
|
104
136
|
}
|
|
105
137
|
|
|
106
|
-
if (position === 'front') this.buffer.unshift(
|
|
107
|
-
else this.buffer.push(
|
|
138
|
+
if (position === 'front') this.buffer.unshift(bytes);
|
|
139
|
+
else this.buffer.push(bytes);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private deliverBackText(text: string): void {
|
|
143
|
+
this.deliver(encode(text), 'front');
|
|
108
144
|
}
|
|
109
145
|
}
|
package/src/vfs/sqlite-vfs.ts
CHANGED
|
@@ -614,10 +614,9 @@ export class SqliteVFS {
|
|
|
614
614
|
|
|
615
615
|
// ── Running counters for O(1) getStats() (B3 / AUDIT M10 / M-S8) ──
|
|
616
616
|
// Replaces the triple scan of this.inodes on every /api/stats poll.
|
|
617
|
-
// Bootstrapped in loadInodes(); maintained at
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
// walk of this.inodes. Unit-tested in the A5/B3 runtime tests.
|
|
617
|
+
// Bootstrapped in loadInodes(); maintained at committed publication by
|
|
618
|
+
// mkdir, batch writes/deletes and rename. These match a fresh O(N)
|
|
619
|
+
// walk of this.inodes; rollback reloads them from the durable rows.
|
|
621
620
|
private _totalFiles = 0;
|
|
622
621
|
private _totalDirs = 0;
|
|
623
622
|
private _usedBytes = 0;
|
|
@@ -632,6 +631,10 @@ export class SqliteVFS {
|
|
|
632
631
|
// so unrelated writes no longer invalidate them. In-memory only — the
|
|
633
632
|
// clock resets with the DO lifetime, exactly like the caches keyed on it.
|
|
634
633
|
private _pathRevisions = new Map<string, number>();
|
|
634
|
+
private transactionPublication: {
|
|
635
|
+
paths: Set<string>;
|
|
636
|
+
events: { type: VfsEventType; path: string; oldPath?: string }[];
|
|
637
|
+
} | null = null;
|
|
635
638
|
|
|
636
639
|
// ── Invalidation log (facet cache coherence) ──────────────────────────
|
|
637
640
|
// A facet's resident set is a cache of this VFS, and it learns what to
|
|
@@ -1673,6 +1676,10 @@ export class SqliteVFS {
|
|
|
1673
1676
|
* additional coverage, since no facet view keys on a grandparent.
|
|
1674
1677
|
*/
|
|
1675
1678
|
private bumpRevision(paths: readonly string[]): void {
|
|
1679
|
+
if (this.transactionPublication) {
|
|
1680
|
+
for (const path of paths) this.transactionPublication.paths.add(path);
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1676
1683
|
const rev = ++this._revision;
|
|
1677
1684
|
for (const path of paths) {
|
|
1678
1685
|
let p = normalizeVfsPath(path);
|
|
@@ -1884,7 +1891,7 @@ export class SqliteVFS {
|
|
|
1884
1891
|
this._addToChildrenIndex(pp, path);
|
|
1885
1892
|
this._totalDirs++; // B3
|
|
1886
1893
|
this.bumpRevision([path]);
|
|
1887
|
-
this.
|
|
1894
|
+
this.emitMutation('addDir', path);
|
|
1888
1895
|
}
|
|
1889
1896
|
|
|
1890
1897
|
private writeFile(
|
|
@@ -2905,11 +2912,11 @@ export class SqliteVFS {
|
|
|
2905
2912
|
}
|
|
2906
2913
|
const atime = atimeMs !== null && Number.isFinite(atimeMs) ? Math.trunc(atimeMs) : this.now();
|
|
2907
2914
|
const mtime = mtimeMs !== null && Number.isFinite(mtimeMs) ? Math.trunc(mtimeMs) : this.now();
|
|
2915
|
+
this.sql.exec("UPDATE inodes SET atime = ?, mtime = ? WHERE path = ?", atime, mtime, inode.path);
|
|
2908
2916
|
inode.atime = atime;
|
|
2909
2917
|
inode.mtime = mtime;
|
|
2910
|
-
this.sql.exec("UPDATE inodes SET atime = ?, mtime = ? WHERE path = ?", atime, mtime, inode.path);
|
|
2911
2918
|
this.bumpRevision([inode.path]);
|
|
2912
|
-
this.
|
|
2919
|
+
this.emitMutation('change', inode.path);
|
|
2913
2920
|
}
|
|
2914
2921
|
|
|
2915
2922
|
/**
|
|
@@ -2949,10 +2956,10 @@ export class SqliteVFS {
|
|
|
2949
2956
|
}
|
|
2950
2957
|
this.assertMutationsAllowed([inode.path]);
|
|
2951
2958
|
const full = inodeTypeBits(inode.kind) | (mode & 0o7777);
|
|
2952
|
-
inode.mode = full;
|
|
2953
2959
|
this.sql.exec("UPDATE inodes SET mode = ? WHERE path = ?", full, inode.path);
|
|
2960
|
+
inode.mode = full;
|
|
2954
2961
|
this.bumpRevision([inode.path]);
|
|
2955
|
-
this.
|
|
2962
|
+
this.emitMutation('change', inode.path);
|
|
2956
2963
|
}
|
|
2957
2964
|
|
|
2958
2965
|
private chown(
|
|
@@ -2975,18 +2982,21 @@ export class SqliteVFS {
|
|
|
2975
2982
|
}
|
|
2976
2983
|
if (gid !== null && gid === inode.gid && cred.uid !== 0 && !owner) throw vfsError('EPERM', resolved.path);
|
|
2977
2984
|
this.assertMutationsAllowed([inode.path]);
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2985
|
+
const nextUid = uid ?? inode.uid;
|
|
2986
|
+
const nextGid = gid ?? inode.gid;
|
|
2987
|
+
const nextMode = cred.uid === 0 ? inode.mode : inode.mode & ~0o6000;
|
|
2981
2988
|
this.sql.exec(
|
|
2982
2989
|
'UPDATE inodes SET uid = ?, gid = ?, mode = ? WHERE path = ?',
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2990
|
+
nextUid,
|
|
2991
|
+
nextGid,
|
|
2992
|
+
nextMode,
|
|
2986
2993
|
inode.path,
|
|
2987
2994
|
);
|
|
2995
|
+
inode.uid = nextUid;
|
|
2996
|
+
inode.gid = nextGid;
|
|
2997
|
+
inode.mode = nextMode;
|
|
2988
2998
|
this.bumpRevision([inode.path]);
|
|
2989
|
-
this.
|
|
2999
|
+
this.emitMutation('change', inode.path);
|
|
2990
3000
|
}
|
|
2991
3001
|
|
|
2992
3002
|
/**
|
|
@@ -3199,15 +3209,19 @@ export class SqliteVFS {
|
|
|
3199
3209
|
|
|
3200
3210
|
private rename(oldPath: string, newPath: string, cred: VfsCred): void {
|
|
3201
3211
|
this.assertMutationsAllowed([oldPath, newPath]);
|
|
3202
|
-
|
|
3212
|
+
// Resolve private /tmp names to storage keys before reading or mutating.
|
|
3213
|
+
const source = this.checkAccess(oldPath, 0, cred, { followLeaf: false });
|
|
3214
|
+
const inode = source.inode;
|
|
3215
|
+
if (!inode) throw vfsError('ENOENT', oldPath);
|
|
3216
|
+
const target = this.checkAccess(newPath, 0, cred, { followLeaf: false, allowMissingLeaf: true });
|
|
3217
|
+
oldPath = source.path;
|
|
3218
|
+
newPath = target.path;
|
|
3203
3219
|
this.checkParentAccess(oldPath, cred);
|
|
3204
3220
|
this.checkParentAccess(newPath, cred);
|
|
3205
3221
|
if (oldPath === newPath) return;
|
|
3206
3222
|
if (newPath.startsWith(`${oldPath}/`)) {
|
|
3207
3223
|
throw new Error(`EINVAL: cannot move ${oldPath} inside itself`);
|
|
3208
3224
|
}
|
|
3209
|
-
const inode = this.inodes.get(oldPath);
|
|
3210
|
-
if (!inode) throw new Error("ENOENT: " + oldPath);
|
|
3211
3225
|
this.checkStickyParentMutation(oldPath, inode, cred);
|
|
3212
3226
|
|
|
3213
3227
|
// W-3 (WASI filesystem WASI): if newPath already exists, unlink it first so the
|
|
@@ -3217,7 +3231,7 @@ export class SqliteVFS {
|
|
|
3217
3231
|
// a 2nd `make` after a prior successful build throws on UPDATE
|
|
3218
3232
|
// failure. Pre-unlink covers both file-over-file and file-over-dir
|
|
3219
3233
|
// (the latter is rare but POSIX permits it for empty dirs).
|
|
3220
|
-
const destInode =
|
|
3234
|
+
const destInode = target.inode;
|
|
3221
3235
|
if (destInode) {
|
|
3222
3236
|
this.checkStickyParentMutation(newPath, destInode, cred);
|
|
3223
3237
|
if (destInode.isDir) {
|
|
@@ -3381,7 +3395,7 @@ export class SqliteVFS {
|
|
|
3381
3395
|
}
|
|
3382
3396
|
|
|
3383
3397
|
this.bumpRevision([...touchedPaths]);
|
|
3384
|
-
this.
|
|
3398
|
+
this.emitMutation('rename', newPath, oldPath);
|
|
3385
3399
|
this.runContentMaintenanceSafely(1);
|
|
3386
3400
|
}
|
|
3387
3401
|
|
|
@@ -4080,6 +4094,72 @@ export class SqliteVFS {
|
|
|
4080
4094
|
return this.errorMessage(error).toUpperCase().includes('SQLITE_NOMEM');
|
|
4081
4095
|
}
|
|
4082
4096
|
|
|
4097
|
+
/**
|
|
4098
|
+
* Participate in an embedder's synchronous transaction: VFS writes and SQL
|
|
4099
|
+
* issued by callback commit together; on rollback the in-memory mirror
|
|
4100
|
+
* returns to the committed state before the error is rethrown with cause.
|
|
4101
|
+
*
|
|
4102
|
+
* This method MUST own the outermost transaction on this VFS's SQL host;
|
|
4103
|
+
* use it instead of wrapping VFS calls in storage.transactionSync. Do not
|
|
4104
|
+
* nest it, return a Promise/thenable, or start asynchronous work inside it.
|
|
4105
|
+
* The callback may read its writes. Revisions and events publish only on
|
|
4106
|
+
* commit, once for the combined mutation. Existing credential checks apply.
|
|
4107
|
+
*
|
|
4108
|
+
* Inodes are an always-resident cache (absence means ENOENT), so rollback
|
|
4109
|
+
* discards cached chunks and rebuilds metadata/children/counters from SQL.
|
|
4110
|
+
* No inode snapshot or undo log is retained. Recovery failure is surfaced
|
|
4111
|
+
* with both errors; the embedder must discard this VFS in that case.
|
|
4112
|
+
*/
|
|
4113
|
+
withTransaction<T>(callback: () => T): T {
|
|
4114
|
+
if (this.transactionPublication !== null) {
|
|
4115
|
+
throw new Error('[sqlite-vfs] nested embedder transactions are not supported');
|
|
4116
|
+
}
|
|
4117
|
+
const storage = this.ctx.storage;
|
|
4118
|
+
if (!storage) throw new Error('[sqlite-vfs] atomic storage operation requires transactionSync');
|
|
4119
|
+
const publication = {
|
|
4120
|
+
paths: new Set<string>(),
|
|
4121
|
+
events: new Array<{ type: VfsEventType; path: string; oldPath?: string }>(),
|
|
4122
|
+
};
|
|
4123
|
+
const maintenancePending = this.maintenancePending;
|
|
4124
|
+
this.transactionPublication = publication;
|
|
4125
|
+
let result: T;
|
|
4126
|
+
try {
|
|
4127
|
+
result = storage.transactionSync(() => {
|
|
4128
|
+
const value = callback();
|
|
4129
|
+
if (value !== null && (typeof value === 'object' || typeof value === 'function')
|
|
4130
|
+
&& 'then' in value && typeof value.then === 'function') {
|
|
4131
|
+
throw new TypeError('[sqlite-vfs] transaction callback must be synchronous');
|
|
4132
|
+
}
|
|
4133
|
+
return value;
|
|
4134
|
+
});
|
|
4135
|
+
} catch (error) {
|
|
4136
|
+
this.evictAll();
|
|
4137
|
+
this.maintenancePending = maintenancePending;
|
|
4138
|
+
try {
|
|
4139
|
+
this.loadInodes();
|
|
4140
|
+
} catch (reloadError) {
|
|
4141
|
+
throw new AggregateError([error, reloadError], '[sqlite-vfs] transaction rollback reload failed', { cause: error });
|
|
4142
|
+
}
|
|
4143
|
+
throw new Error('[sqlite-vfs] embedder transaction rolled back', { cause: error });
|
|
4144
|
+
} finally {
|
|
4145
|
+
this.transactionPublication = null;
|
|
4146
|
+
}
|
|
4147
|
+
if (publication.paths.size > 0) this.bumpRevision([...publication.paths]);
|
|
4148
|
+
for (const event of publication.events) {
|
|
4149
|
+
this.events.emit(event.type, event.path, event.oldPath);
|
|
4150
|
+
}
|
|
4151
|
+
this.runContentMaintenanceSafely(1);
|
|
4152
|
+
return result;
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
private emitMutation(type: VfsEventType, path: string, oldPath?: string): void {
|
|
4156
|
+
if (this.transactionPublication) {
|
|
4157
|
+
this.transactionPublication.events.push({ type, path, oldPath });
|
|
4158
|
+
} else {
|
|
4159
|
+
this.events.emit(type, path, oldPath);
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4083
4163
|
private transactionSync(callback: () => void): void {
|
|
4084
4164
|
if (!this.ctx?.storage?.transactionSync) {
|
|
4085
4165
|
throw new Error('[sqlite-vfs] atomic storage operation requires transactionSync');
|
|
@@ -4448,6 +4528,7 @@ export class SqliteVFS {
|
|
|
4448
4528
|
}
|
|
4449
4529
|
|
|
4450
4530
|
private runContentMaintenanceSafely(maxTransactions: number, force = false): void {
|
|
4531
|
+
if (this.transactionPublication) return;
|
|
4451
4532
|
if (!force && !this.maintenancePending) return;
|
|
4452
4533
|
const startedAt = performance.now();
|
|
4453
4534
|
try {
|
|
@@ -4773,10 +4854,10 @@ export class SqliteVFS {
|
|
|
4773
4854
|
|
|
4774
4855
|
// 5. Events observe the already-published metadata and revision.
|
|
4775
4856
|
for (const inode of deletedInodes) {
|
|
4776
|
-
this.
|
|
4857
|
+
this.emitMutation(inode.isDir ? 'unlinkDir' : 'unlink', inode.path);
|
|
4777
4858
|
}
|
|
4778
4859
|
for (const entry of plan.inodes) {
|
|
4779
|
-
this.
|
|
4860
|
+
this.emitMutation(entry.isDir ? 'addDir' : replacedPaths.has(entry.path) ? 'change' : 'add', entry.path);
|
|
4780
4861
|
}
|
|
4781
4862
|
|
|
4782
4863
|
this.recordDuration(this._postCommitDuration, performance.now() - postCommitStartedAt);
|
|
@@ -48,6 +48,8 @@ import { seedRuntimePackage, type RuntimePackage } from '../runtime/runtime-pack
|
|
|
48
48
|
import type { EsbuildService } from '../runtime/esbuild-service.js';
|
|
49
49
|
import { registerUnixCommands } from '../shell/unix-commands.js';
|
|
50
50
|
import { installPathExecResolver } from '../shell/exec-dispatch.js';
|
|
51
|
+
import { adoptCtxExports, composeFabric, type CtxExports, type FabricComposition } from '@nimbus-sh/platform/composition.js';
|
|
52
|
+
import { createSupervisorOpHandler, type SupervisorOpEnvelope, type SupervisorOpHandler } from './supervisor-op.js';
|
|
51
53
|
|
|
52
54
|
export interface NimbusWorkspaceOptions {
|
|
53
55
|
/** The host's SQLite. In a Durable Object: `ctx.storage.sql`. */
|
|
@@ -126,6 +128,15 @@ export interface NimbusWorkspaceOptions {
|
|
|
126
128
|
* carry REPLs and a resident-process substrate this cannot reach.
|
|
127
129
|
*/
|
|
128
130
|
readonly facets?: FacetHost;
|
|
131
|
+
/** Isolate-wide fabric composition, including the hosting namespace. */
|
|
132
|
+
readonly fabric?: FabricComposition;
|
|
133
|
+
/** Explicit exports override the bag on the transaction host. */
|
|
134
|
+
readonly ctxExports?: CtxExports;
|
|
135
|
+
/** The process table that allocated supervisor-binding pids. */
|
|
136
|
+
readonly processes?: SessionProcessSupervisor;
|
|
137
|
+
readonly processOutput?: (stream: 'stdout' | 'stderr', pid: number, data: string) => void;
|
|
138
|
+
/** Host operations, including overrides for host-specific accounting. */
|
|
139
|
+
readonly supervisorOps?: Readonly<Record<string, SupervisorOpHandler>>;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
/**
|
|
@@ -168,6 +179,7 @@ export class NimbusWorkspace {
|
|
|
168
179
|
registry: CommandRegistry,
|
|
169
180
|
env: Record<string, string>,
|
|
170
181
|
private readonly sql: SqlDatabase,
|
|
182
|
+
private readonly supervisorOps: (envelope: SupervisorOpEnvelope) => Promise<unknown>,
|
|
171
183
|
) {
|
|
172
184
|
this.vfs = vfs;
|
|
173
185
|
this.kernel = kernel;
|
|
@@ -182,6 +194,10 @@ export class NimbusWorkspace {
|
|
|
182
194
|
}
|
|
183
195
|
|
|
184
196
|
static async create(options: NimbusWorkspaceOptions): Promise<NimbusWorkspace> {
|
|
197
|
+
if (options.fabric) composeFabric(options.fabric);
|
|
198
|
+
const exports = options.ctxExports
|
|
199
|
+
?? (options.transactions as (TransactionHost & { exports?: CtxExports }) | undefined)?.exports;
|
|
200
|
+
if (exports) adoptCtxExports(exports);
|
|
185
201
|
const vfs = options.vfs ?? openFilesystem(options);
|
|
186
202
|
const mounts = options.mounts ?? DEFAULT_MOUNT_POINTS;
|
|
187
203
|
seedBaseFilesystem(vfs, mounts);
|
|
@@ -234,13 +250,21 @@ export class NimbusWorkspace {
|
|
|
234
250
|
});
|
|
235
251
|
}
|
|
236
252
|
|
|
237
|
-
|
|
253
|
+
const supervisorOps = createSupervisorOpHandler({
|
|
254
|
+
vfs, processes: options.processes, output: options.processOutput, extend: options.supervisorOps,
|
|
255
|
+
});
|
|
256
|
+
return new NimbusWorkspace(vfs, kernel, shell, registry, env, options.sql, supervisorOps);
|
|
238
257
|
}
|
|
239
258
|
|
|
240
259
|
exec(command: string, options?: RunOptions): Promise<CommandResult> {
|
|
241
260
|
return this.commands.run(command, options);
|
|
242
261
|
}
|
|
243
262
|
|
|
263
|
+
/** The hosting object forwards its supervisorOp RPC to this method. */
|
|
264
|
+
supervisorOp(envelope: SupervisorOpEnvelope): Promise<unknown> {
|
|
265
|
+
return this.supervisorOps(envelope);
|
|
266
|
+
}
|
|
267
|
+
|
|
244
268
|
/**
|
|
245
269
|
* Apply the login files, and begin reading the terminal when there is one.
|
|
246
270
|
*
|
|
@@ -383,7 +407,7 @@ async function registerWasmRuntimes(deps: {
|
|
|
383
407
|
getEsbuild: () => {
|
|
384
408
|
if (!esbuild) {
|
|
385
409
|
esbuild = import('../runtime/esbuild-service.js')
|
|
386
|
-
.then((module) => new module.EsbuildService(deps.vfs));
|
|
410
|
+
.then((module) => new module.EsbuildService(deps.vfs.as(CRED_KERNEL)));
|
|
387
411
|
}
|
|
388
412
|
return esbuild;
|
|
389
413
|
},
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { SqliteVFS } from '../vfs/sqlite-vfs.js';
|
|
2
|
+
import { CRED_SESSION_USER, type VfsCred } from '../runtime/os-contracts.js';
|
|
3
|
+
import { SqliteRuntimeFsBridge } from '../runtime/sqlite-runtime-fs-bridge.js';
|
|
4
|
+
import { getSymlinkRegistry } from '../vfs/symlink-registry.js';
|
|
5
|
+
import type { SessionProcessSupervisor } from '../runtime/session-process-supervisor.js';
|
|
6
|
+
|
|
7
|
+
/** Identity comes from the supervisor binding, never from facet arguments. */
|
|
8
|
+
export interface SupervisorOpEnvelope {
|
|
9
|
+
readonly op: string;
|
|
10
|
+
readonly args?: readonly unknown[];
|
|
11
|
+
readonly pid?: number;
|
|
12
|
+
readonly writerId?: string;
|
|
13
|
+
readonly mutationOwner?: string;
|
|
14
|
+
readonly stream?: ReadableStream<Uint8Array>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SupervisorOpHandler = (envelope: SupervisorOpEnvelope) => unknown;
|
|
18
|
+
|
|
19
|
+
export interface SupervisorOpDeps {
|
|
20
|
+
readonly vfs: SqliteVFS;
|
|
21
|
+
/** Absent a process table, operations use the unprivileged session user. */
|
|
22
|
+
readonly processes?: SessionProcessSupervisor;
|
|
23
|
+
readonly output?: (stream: 'stdout' | 'stderr', pid: number, data: string) => void;
|
|
24
|
+
/** Host handlers override defaults, for example to account for stream drains. */
|
|
25
|
+
readonly extend?: Readonly<Record<string, SupervisorOpHandler>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stringArg(envelope: SupervisorOpEnvelope, index: number): string {
|
|
29
|
+
const value = envelope.args?.[index];
|
|
30
|
+
if (typeof value !== 'string') {
|
|
31
|
+
throw new Error(`supervisor op ${envelope.op}: argument ${index} must be a string`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function numberArg(envelope: SupervisorOpEnvelope, index: number): number {
|
|
37
|
+
const value = envelope.args?.[index];
|
|
38
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
39
|
+
throw new Error(`supervisor op ${envelope.op}: argument ${index} must be a number`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contentArg(envelope: SupervisorOpEnvelope, index: number): string | Uint8Array {
|
|
45
|
+
const value = envelope.args?.[index];
|
|
46
|
+
if (typeof value === 'string' || value instanceof Uint8Array) return value;
|
|
47
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
48
|
+
throw new Error(`supervisor op ${envelope.op}: argument ${index} must be bytes or text`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function credFor(deps: SupervisorOpDeps, pid: number | undefined): VfsCred {
|
|
52
|
+
if (pid === undefined) return CRED_SESSION_USER;
|
|
53
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
54
|
+
throw new Error('supervisor op: filesystem operation requires a valid process pid');
|
|
55
|
+
}
|
|
56
|
+
return deps.processes ? deps.processes.cred(pid) : CRED_SESSION_USER;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One dispatch method lets any host serve its workspace to process facets. */
|
|
60
|
+
export function createSupervisorOpHandler(
|
|
61
|
+
deps: SupervisorOpDeps,
|
|
62
|
+
): (envelope: SupervisorOpEnvelope) => Promise<unknown> {
|
|
63
|
+
const bridges = new Map<number, SqliteRuntimeFsBridge>();
|
|
64
|
+
const bridgeFor = (pid: number | undefined): SqliteRuntimeFsBridge => {
|
|
65
|
+
const key = pid ?? 0;
|
|
66
|
+
const credentialed = deps.vfs.as(credFor(deps, pid));
|
|
67
|
+
const held = bridges.get(key);
|
|
68
|
+
if (held) {
|
|
69
|
+
held.updateCredential(credentialed);
|
|
70
|
+
return held;
|
|
71
|
+
}
|
|
72
|
+
const built = new SqliteRuntimeFsBridge(credentialed, deps.vfs);
|
|
73
|
+
bridges.set(key, built);
|
|
74
|
+
return built;
|
|
75
|
+
};
|
|
76
|
+
const ops: Readonly<Record<string, SupervisorOpHandler>> = {
|
|
77
|
+
readFile: async (e) => {
|
|
78
|
+
const bytes = await bridgeFor(e.pid).readFile(stringArg(e, 0));
|
|
79
|
+
return bytes === null ? null : new TextDecoder().decode(bytes);
|
|
80
|
+
},
|
|
81
|
+
readFileBytes: (e) => bridgeFor(e.pid).readFile(stringArg(e, 0)),
|
|
82
|
+
stat: (e) => bridgeFor(e.pid).stat(stringArg(e, 0)),
|
|
83
|
+
lstat: (e) => bridgeFor(e.pid).stat(stringArg(e, 0), { followSymlinks: false }),
|
|
84
|
+
exists: async (e) => (await bridgeFor(e.pid).stat(stringArg(e, 0))) !== null,
|
|
85
|
+
readdir: (e) => bridgeFor(e.pid).readdir(stringArg(e, 0)),
|
|
86
|
+
readlink: (e) => bridgeFor(e.pid).readlink(stringArg(e, 0)),
|
|
87
|
+
fsReadRange: (e) => bridgeFor(e.pid).readRange(stringArg(e, 0), numberArg(e, 1), numberArg(e, 2)),
|
|
88
|
+
fsReadRangeUncached: (e) => bridgeFor(e.pid).readRange(stringArg(e, 0), numberArg(e, 1), numberArg(e, 2), { cached: false }),
|
|
89
|
+
fsRevision: (e) => bridgeFor(e.pid).revision(e.args?.[0] === undefined ? undefined : stringArg(e, 0)),
|
|
90
|
+
hasLegacySymlinkUnder: (e) => getSymlinkRegistry(deps.vfs).hasAtOrBelow(stringArg(e, 0)),
|
|
91
|
+
writeFile: (e) => bridgeFor(e.pid).writeFile(stringArg(e, 0), contentArg(e, 1)),
|
|
92
|
+
mkdir: (e) => bridgeFor(e.pid).mkdir(stringArg(e, 0), { recursive: true }),
|
|
93
|
+
rmdir: (e) => bridgeFor(e.pid).rmdir(stringArg(e, 0)),
|
|
94
|
+
unlink: (e) => bridgeFor(e.pid).unlink(stringArg(e, 0)),
|
|
95
|
+
rename: (e) => bridgeFor(e.pid).rename(stringArg(e, 0), stringArg(e, 1)),
|
|
96
|
+
symlink: (e) => bridgeFor(e.pid).symlink(stringArg(e, 0), stringArg(e, 1)),
|
|
97
|
+
chmod: (e) => bridgeFor(e.pid).chmod(stringArg(e, 0), numberArg(e, 1)),
|
|
98
|
+
utimes: (e) => bridgeFor(e.pid).utimes(stringArg(e, 0), numberArg(e, 1), numberArg(e, 2)),
|
|
99
|
+
fsTruncate: (e) => bridgeFor(e.pid).truncate(stringArg(e, 0), numberArg(e, 1)),
|
|
100
|
+
writeBatchStream: (e) => {
|
|
101
|
+
if (!e.stream) throw new Error('supervisor op writeBatchStream: no stream');
|
|
102
|
+
return deps.vfs.as(credFor(deps, e.pid)).writeStream(e.stream, { mutationOwner: e.mutationOwner });
|
|
103
|
+
},
|
|
104
|
+
stdout: (e) => { deps.output?.('stdout', e.pid ?? 0, stringArg(e, 0)); },
|
|
105
|
+
stderr: (e) => { deps.output?.('stderr', e.pid ?? 0, stringArg(e, 0)); },
|
|
106
|
+
};
|
|
107
|
+
const extend = deps.extend ?? {};
|
|
108
|
+
return async (envelope) => {
|
|
109
|
+
if (!envelope || typeof envelope.op !== 'string') {
|
|
110
|
+
throw new Error('supervisor op: envelope names no operation');
|
|
111
|
+
}
|
|
112
|
+
const handler = Object.hasOwn(extend, envelope.op) ? extend[envelope.op]
|
|
113
|
+
: Object.hasOwn(ops, envelope.op) ? ops[envelope.op] : undefined;
|
|
114
|
+
if (!handler) throw new Error(`supervisor op: '${envelope.op}' is not served by this host`);
|
|
115
|
+
return handler(envelope);
|
|
116
|
+
};
|
|
117
|
+
}
|