@ricsam/r5d-worker 0.0.166 → 0.0.168
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/dist/cjs/package.json +1 -1
- package/dist/mjs/internal-r5dctl.cjs +1 -1
- package/dist/mjs/main.mjs +2 -2
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/personal/client.mjs +1 -1
- package/dist/mjs/personal/runtime.mjs +2 -0
- package/dist/mjs/runtime/workspace/authority.mjs +24 -14
- package/dist/mjs/runtime/workspace/search.mjs +120 -0
- package/dist/mjs/runtime/workspace/storage-client.mjs +57 -9
- package/dist/mjs/runtime/workspace/storage-wire.mjs +3 -1
- package/dist/types/runtime/workspace/authority.d.ts +5 -0
- package/dist/types/runtime/workspace/search.d.ts +81 -0
- package/dist/types/runtime/workspace/storage-client.d.ts +35 -1
- package/dist/types/runtime/workspace/storage-wire.d.ts +7 -0
- package/package.json +2 -2
package/dist/cjs/package.json
CHANGED
|
@@ -20605,7 +20605,7 @@ function resolveEntrypointPath(entrypoint) {
|
|
|
20605
20605
|
}
|
|
20606
20606
|
}
|
|
20607
20607
|
function getR5dctlVersion() {
|
|
20608
|
-
if (true) return "0.0.
|
|
20608
|
+
if (true) return "0.0.168";
|
|
20609
20609
|
const entrypoint = process.argv[1] ? resolveEntrypointPath(process.argv[1]) : null;
|
|
20610
20610
|
let current = entrypoint ? import_node_path2.default.dirname(entrypoint) : process.cwd();
|
|
20611
20611
|
for (let index = 0; index < 12; index += 1) {
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { startManagerRpc } from "./runtime/releases/rpc-main.mjs";
|
|
|
7
7
|
import { ManagerRpcConfig } from "./runtime/releases/rpc-protocol.mjs";
|
|
8
8
|
const args = process.argv.slice(2);
|
|
9
9
|
if (args.includes("--version")) {
|
|
10
|
-
console.log(`r5d-worker ${true ? "0.0.
|
|
10
|
+
console.log(`r5d-worker ${true ? "0.0.168" : "development"}`);
|
|
11
11
|
} else if (!args.length || args.includes("--help")) {
|
|
12
12
|
console.log(
|
|
13
13
|
"Usage: r5d-worker start --label <label> [--root <dir>] [--base-url <url>] [--token <worker-token>]\n r5d-worker executor /absolute/private/config.json\n r5d-worker manager /absolute/private/config.json\n\nRun independently supervised durable runtime services. Provision configuration with r5dinfra."
|
|
@@ -15,7 +15,7 @@ if (args.includes("--version")) {
|
|
|
15
15
|
} else if (args[0] === "start") {
|
|
16
16
|
const runtime = await startPersonalWorker(
|
|
17
17
|
parsePersonalWorkerOptions(args.slice(1)),
|
|
18
|
-
true ? "0.0.
|
|
18
|
+
true ? "0.0.168" : "development"
|
|
19
19
|
);
|
|
20
20
|
console.log(`Worker connected: ${runtime.resourceId}`);
|
|
21
21
|
let closing = false;
|
package/dist/mjs/package.json
CHANGED
|
@@ -103,7 +103,7 @@ async function startPersonalWorker(options, version) {
|
|
|
103
103
|
platform: process.platform,
|
|
104
104
|
arch: process.arch,
|
|
105
105
|
hostname: os.hostname(),
|
|
106
|
-
capabilities: { updateClis: true, durableWorkspace: true, outerWorkspace: true, fileWalk: true }
|
|
106
|
+
capabilities: { updateClis: true, durableWorkspace: true, outerWorkspace: true, fileWalk: true, fileSearch: true }
|
|
107
107
|
});
|
|
108
108
|
const grant = PersonalWorkerGrant.parse(
|
|
109
109
|
await request("/api/personal/resources/register", { kind: "worker", ...identity, label: options.label, metadata: metadata() })
|
|
@@ -13,6 +13,7 @@ import { privateDirectory, readPrivateJson } from "../runtime/storage.mjs";
|
|
|
13
13
|
import { WorkspaceAuthority } from "../runtime/workspace/authority.mjs";
|
|
14
14
|
import { ApprovedWorkbench, WorkspaceError } from "../runtime/workspace/contracts.mjs";
|
|
15
15
|
import { OuterSnapshotRefusal } from "../runtime/workspace/outer.mjs";
|
|
16
|
+
import { WorkspaceSearch } from "../runtime/workspace/search.mjs";
|
|
16
17
|
import { WorkspaceStorageClient } from "../runtime/workspace/storage-client.mjs";
|
|
17
18
|
import { BranchName, GitOid, StorageId } from "../runtime/workspace/storage-wire.mjs";
|
|
18
19
|
const PersonalWorkerGrant = z.object({
|
|
@@ -371,6 +372,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
|
|
|
371
372
|
if (command.method === "fileHistoryDates") return authority.fileHistoryDates(identity, command.path);
|
|
372
373
|
if (command.method === "directory" || command.method === "raw") return authority.inspectFiles(identity, command);
|
|
373
374
|
if (command.method === "walk") return authority.walkFiles(identity, command);
|
|
375
|
+
if (command.method === "search") return authority.searchFiles(identity, WorkspaceSearch.parse(command));
|
|
374
376
|
}
|
|
375
377
|
if (request.kind === "terminal") {
|
|
376
378
|
await authority.ensureHydrated(identity);
|
|
@@ -15,6 +15,7 @@ import { OuterRepository, OuterSnapshotRefusal, OUTER_BRANCH, OUTER_CONFLICT_COD
|
|
|
15
15
|
import { SessionArtifactStore, SessionArtifactChunk, RESERVED_ARTIFACT_ENV } from "./artifacts.mjs";
|
|
16
16
|
import { WorkspaceFileWrite, WorkspaceFileWriteLookup } from "./file-write.mjs";
|
|
17
17
|
import { walkDirectoryTree } from "./walk.mjs";
|
|
18
|
+
import { searchDirectoryTree } from "./search.mjs";
|
|
18
19
|
import { WorkspaceStorageClient } from "./storage-client.mjs";
|
|
19
20
|
import {
|
|
20
21
|
durableJson,
|
|
@@ -338,6 +339,25 @@ class WorkspaceAuthority {
|
|
|
338
339
|
const walked = await walkDirectoryTree(target);
|
|
339
340
|
return { directory: display, ...walked };
|
|
340
341
|
}
|
|
342
|
+
/** Searches a tree (or one file) locally in one operation: the walk, every
|
|
343
|
+
* bounded file read and the thread-isolated match all happen here, so a
|
|
344
|
+
* search costs one round-trip however many files it touches. */
|
|
345
|
+
async searchFiles(identity, command) {
|
|
346
|
+
const b = await this.bench(identity);
|
|
347
|
+
const relative = command.hostPath ? "" : command.path.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
348
|
+
if (!command.hostPath && relative) safeTreePath(relative);
|
|
349
|
+
const target = command.hostPath ? this.resolveHostPath(command.path) : path.join(b.config.cwd, relative);
|
|
350
|
+
const display = command.hostPath ? target : `/${relative}`;
|
|
351
|
+
const inspect = command.hostPath ? fs.lstat(target).then((stat) => {
|
|
352
|
+
if (stat.isSymbolicLink()) throw new WorkspaceError("unsafe_path", "Final symlink paths are not inspected");
|
|
353
|
+
}) : noSymlinkAncestors(target);
|
|
354
|
+
await inspect.catch((error) => {
|
|
355
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new WorkspaceError("not_found", "File or directory not found");
|
|
356
|
+
throw error;
|
|
357
|
+
});
|
|
358
|
+
const searched = await searchDirectoryTree(target, { pattern: command.pattern, caseSensitive: command.caseSensitive, glob: command.glob, limit: command.limit, hostPath: command.hostPath });
|
|
359
|
+
return { directory: display, ...searched };
|
|
360
|
+
}
|
|
341
361
|
async inspectGit(identity, command) {
|
|
342
362
|
const b = await this.bench(identity);
|
|
343
363
|
return this.serial(b, async () => {
|
|
@@ -1308,16 +1328,17 @@ class WorkspaceAuthority {
|
|
|
1308
1328
|
async downloadBundle(storage, repositoryId) {
|
|
1309
1329
|
const chunks = [];
|
|
1310
1330
|
let offset = 0, snapshot = null, size = -1, hash = "";
|
|
1331
|
+
const chunkBytes = await storage.chunkBytes();
|
|
1311
1332
|
do {
|
|
1312
1333
|
const chunk = await storage.read({
|
|
1313
1334
|
method: "repository.bundle",
|
|
1314
1335
|
repositoryId,
|
|
1315
1336
|
snapshot,
|
|
1316
1337
|
offset,
|
|
1317
|
-
limit:
|
|
1338
|
+
limit: chunkBytes
|
|
1318
1339
|
});
|
|
1319
1340
|
const bytes = Buffer.from(chunk.data, "base64");
|
|
1320
|
-
if (chunk.data !== bytes.toString("base64") || bytes.length >
|
|
1341
|
+
if (chunk.data !== bytes.toString("base64") || bytes.length > chunkBytes || chunk.offset !== offset || chunk.nextOffset !== offset + bytes.length || !bytes.length || chunk.size > STORAGE_LIMITS.blobBytes || chunk.nextOffset > chunk.size || snapshot && (snapshot !== chunk.snapshot || size !== chunk.size || hash !== chunk.sha256))
|
|
1321
1342
|
throw new WorkspaceError("invalid_bundle", "Chunk identity/size/offset changed; no checkout performed");
|
|
1322
1343
|
snapshot = chunk.snapshot;
|
|
1323
1344
|
size = chunk.size;
|
|
@@ -1330,18 +1351,7 @@ class WorkspaceAuthority {
|
|
|
1330
1351
|
return bundle;
|
|
1331
1352
|
}
|
|
1332
1353
|
async uploadBundle(storage, bytes, blobId, operationId) {
|
|
1333
|
-
|
|
1334
|
-
const end = Math.min(bytes.length, offset + STORAGE_LIMITS.chunkBytes);
|
|
1335
|
-
await storage.mutate({
|
|
1336
|
-
method: "blob.append",
|
|
1337
|
-
operationId: `${operationId}-chunk-${offset}`,
|
|
1338
|
-
blobId,
|
|
1339
|
-
kind: "blob",
|
|
1340
|
-
offset,
|
|
1341
|
-
data: bytes.subarray(offset, end).toString("base64"),
|
|
1342
|
-
seal: end === bytes.length
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1354
|
+
await storage.appendBlob(bytes, blobId, operationId);
|
|
1345
1355
|
}
|
|
1346
1356
|
async unbundleInto(b, bytes) {
|
|
1347
1357
|
const file = path.join(b.directory, `${randomUUID()}.bundle`);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Worker } from "node:worker_threads";
|
|
4
|
+
import picomatch from "picomatch";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { WorkspaceError } from "./contracts.mjs";
|
|
7
|
+
import { readHostRegular, readRegular } from "./files.mjs";
|
|
8
|
+
import { walkDirectoryTree, WORKSPACE_WALK_IGNORED, WORKSPACE_WALK_LIMITS } from "./walk.mjs";
|
|
9
|
+
const WORKSPACE_SEARCH_LIMITS = Object.freeze({
|
|
10
|
+
bytes: 8 * 1024 * 1024,
|
|
11
|
+
matches: 1e3,
|
|
12
|
+
outputBytes: 5e4,
|
|
13
|
+
lineChars: 2e3,
|
|
14
|
+
matchTimeoutMs: 1e3
|
|
15
|
+
});
|
|
16
|
+
const WorkspaceSearch = z.object({
|
|
17
|
+
method: z.literal("search"),
|
|
18
|
+
path: z.string().max(4096),
|
|
19
|
+
pattern: z.string().min(1).max(1e3),
|
|
20
|
+
caseSensitive: z.boolean().optional(),
|
|
21
|
+
glob: z.string().max(1e3).optional(),
|
|
22
|
+
limit: z.number().int().min(1).optional(),
|
|
23
|
+
hostPath: z.boolean().optional()
|
|
24
|
+
}).strict();
|
|
25
|
+
function globMatcher(glob) {
|
|
26
|
+
return glob.includes("/") ? picomatch(glob, { dot: true }) : picomatch(glob, { basename: true, dot: true });
|
|
27
|
+
}
|
|
28
|
+
function compileSearchPattern(pattern, caseSensitive) {
|
|
29
|
+
let source = pattern;
|
|
30
|
+
if (source.startsWith("(?i)")) {
|
|
31
|
+
source = source.slice(4);
|
|
32
|
+
caseSensitive = false;
|
|
33
|
+
}
|
|
34
|
+
if (!source) throw new WorkspaceError("invalid_pattern", "Invalid regular expression", true);
|
|
35
|
+
try {
|
|
36
|
+
new RegExp(source, caseSensitive ? "" : "i");
|
|
37
|
+
} catch {
|
|
38
|
+
throw new WorkspaceError("invalid_pattern", "Invalid regular expression", true);
|
|
39
|
+
}
|
|
40
|
+
return { source, caseSensitive };
|
|
41
|
+
}
|
|
42
|
+
function decodeSearchText(bytes) {
|
|
43
|
+
if (bytes.includes(0)) return null;
|
|
44
|
+
try {
|
|
45
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function matchTextFiles(files, pattern, caseSensitive, maximum, timeoutMs = WORKSPACE_SEARCH_LIMITS.matchTimeoutMs) {
|
|
51
|
+
const compiled = compileSearchPattern(pattern, caseSensitive);
|
|
52
|
+
const limits = { maximum: Math.max(1, Math.min(maximum, WORKSPACE_SEARCH_LIMITS.matches)), outputBytes: WORKSPACE_SEARCH_LIMITS.outputBytes, lineChars: WORKSPACE_SEARCH_LIMITS.lineChars };
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const worker = new Worker(
|
|
55
|
+
"const {parentPort,workerData}=require('node:worker_threads');const r=new RegExp(workerData.source,workerData.caseSensitive?'':'i');const matches=[];let truncated=false,bytes=0;outer:for(const file of workerData.files){let n=0;for(const line of file.text.split('\\n')){n++;if(!r.test(line))continue;const text=line.slice(0,workerData.lineChars),size=file.path.length+text.length+8;if(matches.length>=workerData.maximum||bytes+size>workerData.outputBytes){truncated=true;break outer}matches.push({path:file.path,line:n,text});bytes+=size}}parentPort.postMessage({matches,truncated})",
|
|
56
|
+
{ eval: true, workerData: { files, source: compiled.source, caseSensitive: compiled.caseSensitive, ...limits } }
|
|
57
|
+
);
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
void worker.terminate();
|
|
60
|
+
reject(new WorkspaceError("pattern_budget", "Search pattern exceeded its execution budget; narrow the pattern", true));
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
worker.once("message", (value) => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
void worker.terminate();
|
|
65
|
+
resolve(value);
|
|
66
|
+
});
|
|
67
|
+
worker.once("error", (error) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
void worker.terminate();
|
|
70
|
+
reject(error);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async function searchDirectoryTree(root, options) {
|
|
75
|
+
const compiled = compileSearchPattern(options.pattern, options.caseSensitive ?? true);
|
|
76
|
+
const limits = options.limits ?? WORKSPACE_SEARCH_LIMITS, read = options.hostPath ? readHostRegular : readRegular;
|
|
77
|
+
const stat = await fs.stat(root);
|
|
78
|
+
let candidates, truncated = false;
|
|
79
|
+
if (stat.isFile()) candidates = [{ path: "", size: stat.size }];
|
|
80
|
+
else {
|
|
81
|
+
const matches = options.glob ? globMatcher(options.glob) : () => true;
|
|
82
|
+
const walked = await walkDirectoryTree(root, { ignored: options.ignored ?? WORKSPACE_WALK_IGNORED, limits: WORKSPACE_WALK_LIMITS });
|
|
83
|
+
truncated = walked.truncated;
|
|
84
|
+
candidates = walked.entries.filter((entry) => entry.type === "file" && matches(entry.path));
|
|
85
|
+
}
|
|
86
|
+
const files = [];
|
|
87
|
+
let budget = limits.bytes;
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if ((candidate.size ?? 0) > budget) {
|
|
90
|
+
truncated = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let bytes;
|
|
94
|
+
try {
|
|
95
|
+
bytes = await read(candidate.path ? path.join(root, candidate.path) : root, budget);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const code = error.code;
|
|
98
|
+
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
|
99
|
+
if (error instanceof WorkspaceError) {
|
|
100
|
+
truncated = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
budget -= bytes.length;
|
|
106
|
+
const text = decodeSearchText(bytes);
|
|
107
|
+
if (text !== null) files.push({ path: candidate.path, text });
|
|
108
|
+
}
|
|
109
|
+
const matched = await matchTextFiles(files, compiled.source, compiled.caseSensitive, options.limit ?? limits.matches, limits.matchTimeoutMs);
|
|
110
|
+
return { matches: matched.matches, truncated: truncated || matched.truncated };
|
|
111
|
+
}
|
|
112
|
+
export {
|
|
113
|
+
WORKSPACE_SEARCH_LIMITS,
|
|
114
|
+
WorkspaceSearch,
|
|
115
|
+
compileSearchPattern,
|
|
116
|
+
decodeSearchText,
|
|
117
|
+
globMatcher,
|
|
118
|
+
matchTextFiles,
|
|
119
|
+
searchDirectoryTree
|
|
120
|
+
};
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { OwnershipFence } from "@ricsam/r5d-api/runtime-protocol";
|
|
3
|
-
import { STORAGE_LIMITS, StorageRequest } from "./storage-wire.mjs";
|
|
3
|
+
import { LEGACY_CHUNK_BYTES, STORAGE_LIMITS, StorageRequest } from "./storage-wire.mjs";
|
|
4
4
|
import { WorkspaceError } from "./contracts.mjs";
|
|
5
5
|
import { readBoundedBody } from "./http-body.mjs";
|
|
6
6
|
class WorkspaceStorageClient {
|
|
7
|
-
constructor(installationId, transport, caller) {
|
|
7
|
+
constructor(installationId, transport, caller, helloTtlMs = 1e4) {
|
|
8
8
|
this.installationId = installationId;
|
|
9
9
|
this.transport = transport;
|
|
10
10
|
this.caller = caller;
|
|
11
|
+
this.helloTtlMs = helloTtlMs;
|
|
11
12
|
}
|
|
12
13
|
installationId;
|
|
13
14
|
transport;
|
|
14
15
|
caller;
|
|
16
|
+
helloTtlMs;
|
|
17
|
+
/** The authority's greeting (fence, capabilities, limits) is reused for a
|
|
18
|
+
* short window: every mutation used to pay a second round-trip for it, and a
|
|
19
|
+
* fence that goes stale within the window is refused before admission by the
|
|
20
|
+
* authority itself. Any transport failure discards it. */
|
|
21
|
+
greeting;
|
|
15
22
|
static http(options) {
|
|
16
23
|
const url = new URL(options.url);
|
|
17
24
|
if (url.username || url.password || url.search || url.hash || url.pathname !== "/internal/storage/v1/rpc" || url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
@@ -41,19 +48,60 @@ class WorkspaceStorageClient {
|
|
|
41
48
|
read(request) {
|
|
42
49
|
return this.transport(StorageRequest.parse(request));
|
|
43
50
|
}
|
|
51
|
+
/** The authority's current greeting, fetched at most once per TTL. */
|
|
52
|
+
async hello() {
|
|
53
|
+
if (this.greeting && Date.now() - this.greeting.at < this.helloTtlMs) return this.greeting.value;
|
|
54
|
+
const value = StorageHello.parse(await this.read({ method: "hello" }));
|
|
55
|
+
if (value.installationId !== this.installationId) throw new WorkspaceError("wrong_installation", "Storage authority belongs to another installation");
|
|
56
|
+
this.greeting = { value, at: Date.now() };
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/** Bytes per blob chunk: this client's ceiling, negotiated down to what the
|
|
60
|
+
* authority advertises (an authority predating the advertisement gets the
|
|
61
|
+
* legacy size). */
|
|
62
|
+
async chunkBytes() {
|
|
63
|
+
const advertised = (await this.hello()).limits?.chunkBytes ?? LEGACY_CHUNK_BYTES;
|
|
64
|
+
return Math.max(1, Math.min(STORAGE_LIMITS.chunkBytes, advertised));
|
|
65
|
+
}
|
|
44
66
|
async mutate(input) {
|
|
45
|
-
const hello =
|
|
46
|
-
protocol: z.literal(1),
|
|
47
|
-
installationId: z.literal(this.installationId),
|
|
48
|
-
fence: OwnershipFence,
|
|
49
|
-
capabilities: z.array(z.string())
|
|
50
|
-
}).passthrough().parse(await this.read({ method: "hello" }));
|
|
67
|
+
const hello = await this.hello();
|
|
51
68
|
const callerFence = OwnershipFence.parse(await this.caller());
|
|
52
69
|
if (hello.fence.installationId !== this.installationId || callerFence.installationId !== this.installationId || callerFence.resourceType === "storage-authority")
|
|
53
70
|
throw new WorkspaceError("wrong_installation", "Separate installation-bound caller ownership required");
|
|
54
|
-
|
|
71
|
+
try {
|
|
72
|
+
return await this.transport(StorageRequest.parse({ ...input, fence: hello.fence, callerFence }));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
this.greeting = void 0;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Uploads `bytes` as blob `blobId` in ordered, negotiated-size chunks; the
|
|
79
|
+
* last append seals it. Each chunk is its own durable operation
|
|
80
|
+
* (`<operationId>-chunk-<offset>`), so a repeated upload of the same bytes
|
|
81
|
+
* settles against the original receipts. */
|
|
82
|
+
async appendBlob(bytes, blobId, operationId) {
|
|
83
|
+
const chunkBytes = await this.chunkBytes();
|
|
84
|
+
for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
|
|
85
|
+
const end = Math.min(bytes.length, offset + chunkBytes);
|
|
86
|
+
await this.mutate({
|
|
87
|
+
method: "blob.append",
|
|
88
|
+
operationId: `${operationId}-chunk-${offset}`,
|
|
89
|
+
blobId,
|
|
90
|
+
kind: "blob",
|
|
91
|
+
offset,
|
|
92
|
+
data: bytes.subarray(offset, end).toString("base64"),
|
|
93
|
+
seal: end === bytes.length
|
|
94
|
+
});
|
|
95
|
+
}
|
|
55
96
|
}
|
|
56
97
|
}
|
|
98
|
+
const StorageHello = z.object({
|
|
99
|
+
protocol: z.literal(1),
|
|
100
|
+
installationId: z.string(),
|
|
101
|
+
fence: OwnershipFence,
|
|
102
|
+
capabilities: z.array(z.string()),
|
|
103
|
+
limits: z.object({ chunkBytes: z.number().int().min(1).optional() }).passthrough().optional()
|
|
104
|
+
}).passthrough();
|
|
57
105
|
export {
|
|
58
106
|
WorkspaceStorageClient
|
|
59
107
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { OwnershipFence, RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
|
|
3
|
-
const STORAGE_LIMITS = { chunkBytes:
|
|
3
|
+
const STORAGE_LIMITS = { chunkBytes: 1024 * 1024, blobBytes: 128 * 1024 * 1024, requestBytes: 1536 * 1024, treeEntries: 2e5 };
|
|
4
|
+
const LEGACY_CHUNK_BYTES = 64 * 1024;
|
|
4
5
|
const StorageId = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$/);
|
|
5
6
|
const GitOid = z.string().regex(/^[0-9a-f]{40}$/);
|
|
6
7
|
const BranchName = z.string().min(1).max(150).refine((value) => value.split("/").every((part) => /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(part)));
|
|
@@ -49,6 +50,7 @@ function safeTreePath(value) {
|
|
|
49
50
|
export {
|
|
50
51
|
BranchName,
|
|
51
52
|
GitOid,
|
|
53
|
+
LEGACY_CHUNK_BYTES,
|
|
52
54
|
STORAGE_LIMITS,
|
|
53
55
|
StorageId,
|
|
54
56
|
StorageRequest,
|
|
@@ -4,6 +4,7 @@ import { WorkspaceConfig, type ExecutorRoute, type WorkspaceIdentity, type Outer
|
|
|
4
4
|
import { SessionArtifactChunk } from "./artifacts";
|
|
5
5
|
import { WorkspaceFileWrite } from "./file-write";
|
|
6
6
|
import { type WorkspaceWalk, type WorkspaceWalkResult } from "./walk";
|
|
7
|
+
import { type WorkspaceSearch, type WorkspaceSearchResult } from "./search";
|
|
7
8
|
import { WorkspaceStorageClient } from "./storage-client";
|
|
8
9
|
export interface WorkspaceAuthorityOptions {
|
|
9
10
|
config: WorkspaceConfig;
|
|
@@ -112,6 +113,10 @@ export declare class WorkspaceAuthority {
|
|
|
112
113
|
* relative to the walked directory; the shared ignore list and bounds live
|
|
113
114
|
* in `./walk` so the server's fallback agrees with them. */
|
|
114
115
|
walkFiles(identity: WorkspaceIdentity, command: WorkspaceWalk): Promise<WorkspaceWalkResult>;
|
|
116
|
+
/** Searches a tree (or one file) locally in one operation: the walk, every
|
|
117
|
+
* bounded file read and the thread-isolated match all happen here, so a
|
|
118
|
+
* search costs one round-trip however many files it touches. */
|
|
119
|
+
searchFiles(identity: WorkspaceIdentity, command: WorkspaceSearch): Promise<WorkspaceSearchResult>;
|
|
115
120
|
inspectGit(identity: WorkspaceIdentity, command: {
|
|
116
121
|
method: "status" | "diff" | "history" | "fileDiff";
|
|
117
122
|
path?: string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export type WorkspaceSearchLimits = {
|
|
3
|
+
/** Bytes of file content one search may read in total. */
|
|
4
|
+
readonly bytes: number;
|
|
5
|
+
/** Match lines reported before the result is marked truncated. */
|
|
6
|
+
readonly matches: number;
|
|
7
|
+
/** Bytes of reported match text. */
|
|
8
|
+
readonly outputBytes: number;
|
|
9
|
+
/** Characters kept from a matching line. */
|
|
10
|
+
readonly lineChars: number;
|
|
11
|
+
/** Wall-clock budget for regular-expression execution. */
|
|
12
|
+
readonly matchTimeoutMs: number;
|
|
13
|
+
};
|
|
14
|
+
/** Bounds one text search regardless of what a checkout contains. */
|
|
15
|
+
export declare const WORKSPACE_SEARCH_LIMITS: WorkspaceSearchLimits;
|
|
16
|
+
/** Inspect command: search text files under a path in one worker operation.
|
|
17
|
+
* `glob` matches paths relative to `path` (a glob without a slash matches
|
|
18
|
+
* base names). `limit` is clamped to the shared match bound. */
|
|
19
|
+
export declare const WorkspaceSearch: z.ZodObject<{
|
|
20
|
+
method: z.ZodLiteral<"search">;
|
|
21
|
+
path: z.ZodString;
|
|
22
|
+
pattern: z.ZodString;
|
|
23
|
+
caseSensitive: z.ZodOptional<z.ZodBoolean>;
|
|
24
|
+
glob: z.ZodOptional<z.ZodString>;
|
|
25
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
26
|
+
hostPath: z.ZodOptional<z.ZodBoolean>;
|
|
27
|
+
}, z.core.$strict>;
|
|
28
|
+
export type WorkspaceSearch = z.infer<typeof WorkspaceSearch>;
|
|
29
|
+
/** A match's `path` is relative to the searched directory, or empty when the
|
|
30
|
+
* searched path was itself a file. */
|
|
31
|
+
export type WorkspaceSearchMatch = {
|
|
32
|
+
path: string;
|
|
33
|
+
line: number;
|
|
34
|
+
text: string;
|
|
35
|
+
};
|
|
36
|
+
export type WorkspaceSearchResult = {
|
|
37
|
+
directory: string;
|
|
38
|
+
matches: WorkspaceSearchMatch[];
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** A glob without a slash matches base names anywhere under the searched
|
|
42
|
+
* directory; one with a slash matches the whole relative path. picomatch's
|
|
43
|
+
* `basename` option silently fails slash patterns, so it is applied only to
|
|
44
|
+
* slash-free globs. */
|
|
45
|
+
export declare function globMatcher(glob: string): (relative: string) => boolean;
|
|
46
|
+
/** Validates a search pattern before any file is touched. A leading `(?i)`,
|
|
47
|
+
* the inline flag models trained on ripgrep and PCRE emit, is folded into
|
|
48
|
+
* case-insensitivity because JavaScript has no inline flag syntax. */
|
|
49
|
+
export declare function compileSearchPattern(pattern: string, caseSensitive: boolean): {
|
|
50
|
+
source: string;
|
|
51
|
+
caseSensitive: boolean;
|
|
52
|
+
};
|
|
53
|
+
/** Decodes a file as UTF-8 text, or returns null for binary or non-UTF-8 content. */
|
|
54
|
+
export declare function decodeSearchText(bytes: Buffer): string | null;
|
|
55
|
+
/** Runs the regular expression in a disposable thread so a hostile pattern
|
|
56
|
+
* cannot stall the process's event loop; the thread is terminated at the
|
|
57
|
+
* budget. `maximum` bounds reported matches. */
|
|
58
|
+
export declare function matchTextFiles(files: Array<{
|
|
59
|
+
path: string;
|
|
60
|
+
text: string;
|
|
61
|
+
}>, pattern: string, caseSensitive: boolean, maximum: number, timeoutMs?: number): Promise<{
|
|
62
|
+
matches: WorkspaceSearchMatch[];
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
}>;
|
|
65
|
+
/** Searches the tree under `root` (or `root` itself when it is a file) in
|
|
66
|
+
* process: one walk with the shared ignore list, bounded reads of the files
|
|
67
|
+
* the glob selects, and one thread-isolated match. Files that vanish, exceed
|
|
68
|
+
* the remaining byte budget, or are not UTF-8 text are skipped; the first two
|
|
69
|
+
* mark the result truncated. */
|
|
70
|
+
export declare function searchDirectoryTree(root: string, options: {
|
|
71
|
+
pattern: string;
|
|
72
|
+
caseSensitive?: boolean;
|
|
73
|
+
glob?: string;
|
|
74
|
+
limit?: number;
|
|
75
|
+
hostPath?: boolean;
|
|
76
|
+
ignored?: ReadonlySet<string>;
|
|
77
|
+
limits?: WorkspaceSearchLimits;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
matches: WorkspaceSearchMatch[];
|
|
80
|
+
truncated: boolean;
|
|
81
|
+
}>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { OwnershipFence } from "@ricsam/r5d-api/runtime-protocol";
|
|
2
3
|
import { StorageRequest, type StorageMutation } from "./storage-wire";
|
|
3
4
|
export type StorageTransport = (request: StorageRequest) => Promise<unknown>;
|
|
@@ -9,7 +10,13 @@ export declare class WorkspaceStorageClient {
|
|
|
9
10
|
readonly installationId: string;
|
|
10
11
|
private readonly transport;
|
|
11
12
|
private readonly caller;
|
|
12
|
-
|
|
13
|
+
private readonly helloTtlMs;
|
|
14
|
+
/** The authority's greeting (fence, capabilities, limits) is reused for a
|
|
15
|
+
* short window: every mutation used to pay a second round-trip for it, and a
|
|
16
|
+
* fence that goes stale within the window is refused before admission by the
|
|
17
|
+
* authority itself. Any transport failure discards it. */
|
|
18
|
+
private greeting?;
|
|
19
|
+
constructor(installationId: string, transport: StorageTransport, caller: () => Promise<OwnershipFence>, helloTtlMs?: number);
|
|
13
20
|
static http(options: {
|
|
14
21
|
installationId: string;
|
|
15
22
|
url: string;
|
|
@@ -18,6 +25,33 @@ export declare class WorkspaceStorageClient {
|
|
|
18
25
|
fetch?: typeof fetch;
|
|
19
26
|
}): WorkspaceStorageClient;
|
|
20
27
|
read<T>(request: Exclude<StorageRequest, StorageMutation>): Promise<T>;
|
|
28
|
+
/** The authority's current greeting, fetched at most once per TTL. */
|
|
29
|
+
hello(): Promise<StorageHello>;
|
|
30
|
+
/** Bytes per blob chunk: this client's ceiling, negotiated down to what the
|
|
31
|
+
* authority advertises (an authority predating the advertisement gets the
|
|
32
|
+
* legacy size). */
|
|
33
|
+
chunkBytes(): Promise<number>;
|
|
21
34
|
mutate<T>(input: MutationInput): Promise<T>;
|
|
35
|
+
/** Uploads `bytes` as blob `blobId` in ordered, negotiated-size chunks; the
|
|
36
|
+
* last append seals it. Each chunk is its own durable operation
|
|
37
|
+
* (`<operationId>-chunk-<offset>`), so a repeated upload of the same bytes
|
|
38
|
+
* settles against the original receipts. */
|
|
39
|
+
appendBlob(bytes: Buffer, blobId: string, operationId: string): Promise<void>;
|
|
22
40
|
}
|
|
41
|
+
declare const StorageHello: z.ZodObject<{
|
|
42
|
+
protocol: z.ZodLiteral<1>;
|
|
43
|
+
installationId: z.ZodString;
|
|
44
|
+
fence: z.ZodObject<{
|
|
45
|
+
installationId: z.ZodString;
|
|
46
|
+
resourceType: z.ZodString;
|
|
47
|
+
resourceId: z.ZodString;
|
|
48
|
+
ownerId: z.ZodString;
|
|
49
|
+
epoch: z.ZodNumber;
|
|
50
|
+
}, z.core.$strict>;
|
|
51
|
+
capabilities: z.ZodArray<z.ZodString>;
|
|
52
|
+
limits: z.ZodOptional<z.ZodObject<{
|
|
53
|
+
chunkBytes: z.ZodOptional<z.ZodNumber>;
|
|
54
|
+
}, z.core.$loose>>;
|
|
55
|
+
}, z.core.$loose>;
|
|
56
|
+
export type StorageHello = z.infer<typeof StorageHello>;
|
|
23
57
|
export {};
|
|
@@ -2,12 +2,19 @@ import { z } from "zod";
|
|
|
2
2
|
/** Client-side v1 wire contract only. No server/control/catalog filesystem imports.
|
|
3
3
|
* Keep matched to storage RPC; integration tests exercise the real authenticated parser.
|
|
4
4
|
*/
|
|
5
|
+
/** `chunkBytes` is this client's ceiling; the size actually used is negotiated
|
|
6
|
+
* down to what the storage authority's `hello` advertises (`limits.chunkBytes`),
|
|
7
|
+
* so a newer worker keeps talking to an older authority. A blob's chunks are
|
|
8
|
+
* appended in order, one round-trip each: with 64 KiB chunks a 3 MB bundle
|
|
9
|
+
* cost 32 round-trips through the relay — minutes per publication. */
|
|
5
10
|
export declare const STORAGE_LIMITS: {
|
|
6
11
|
readonly chunkBytes: number;
|
|
7
12
|
readonly blobBytes: number;
|
|
8
13
|
readonly requestBytes: number;
|
|
9
14
|
readonly treeEntries: 200000;
|
|
10
15
|
};
|
|
16
|
+
/** Chunk size assumed for an authority whose hello advertises no limits. */
|
|
17
|
+
export declare const LEGACY_CHUNK_BYTES: number;
|
|
11
18
|
export declare const StorageId: z.ZodString;
|
|
12
19
|
export declare const GitOid: z.ZodString;
|
|
13
20
|
export declare const BranchName: z.ZodString;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ricsam/r5d-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.168",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/mjs/main.mjs",
|
|
6
6
|
"module": "./dist/mjs/main.mjs",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"r5d-worker": "dist/mjs/main.mjs"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@ricsam/r5d-api": "^0.0.
|
|
24
|
+
"@ricsam/r5d-api": "^0.0.168",
|
|
25
25
|
"node-pty": "1.1.0",
|
|
26
26
|
"zod": "^4.1.13",
|
|
27
27
|
"picomatch": "^4.0.3"
|