@compforge/harness-toolbox 0.4.1 → 0.4.2

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 CHANGED
@@ -18,45 +18,49 @@ not load database or Kubernetes drivers.
18
18
 
19
19
  ## File sources
20
20
 
21
- Use `LocalFileDataSource` for a local file or `PodFileDataSource` for a file in an explicit
22
- namespace/Pod/container. Both extend Common's `FileDataSource`, so a Service can store them directly
23
- as DataSources and consumers can recognize files with `instanceof FileDataSource`. No additional
24
- `id` or `kind` wrapper is required; consumer-owned selection names remain separate from client identity.
21
+ A FileDataSource is a caller-declared acquisition group. Use `LocalFileDataSource` for local paths
22
+ or `PodFileDataSource` for paths in one explicit namespace/Pod/container. Both extend Common's
23
+ `FileDataSource`; no additional id/kind wrapper is required.
25
24
 
26
25
  ```ts
27
- import { ClientManager, FileDataSource } from "@compforge/harness-common";
26
+ import { ClientManager } from "@compforge/harness-common";
28
27
  import { LocalFileDataSource, PodFileDataSource } from "@compforge/harness-toolbox/file";
29
28
 
30
- const source = new LocalFileDataSource("./config.yaml", { concurrency: 2 });
29
+ const source = new LocalFileDataSource(["./config.yaml", "./secret/config.yaml"], { concurrency: 2 });
31
30
  const clients = new ClientManager();
32
31
  try {
33
- if (source instanceof FileDataSource) {
34
- const file = await clients.get(source);
35
- const bytes = await file.read({ maxBytes: 1024 * 1024, timeoutMs: 15_000 });
36
- console.log(source.path, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
32
+ const file = await clients.get(source);
33
+ const results = await file.read({ maxBytes: 1024 * 1024, maxTotalBytes: 2 * 1024 * 1024, timeoutMs: 15_000 });
34
+ for (const result of results) {
35
+ if (result.status === "error") console.error(result.path, result.error.message);
36
+ else console.log(result.path, new TextDecoder("utf-8", { fatal: true }).decode(result.bytes));
37
37
  }
38
- } finally {
39
- await clients.dispose();
40
- }
38
+ } finally { await clients.dispose(); }
41
39
 
42
- // `environment` is a KubernetesEnvironment (or an equivalent ClientProvider).
43
- // Resolve Workloads first and create one source per chosen Pod/container.
40
+ // Resolve Workloads first. This entire group is fetched through one exec session.
44
41
  const podSource = new PodFileDataSource(environment,
45
42
  { namespace: "app", pod: "worker-abc", container: "worker" },
46
- "/etc/app/config.yaml", { concurrency: 2 });
43
+ ["/etc/app/config.yaml", "/etc/app/secret/config.yaml"], { concurrency: 2 });
47
44
  ```
48
45
 
49
- Each read reopens the file; the manager shares clients, not previous file contents. The byte limit
50
- rejects oversized reads, including a zero-byte limit on a nonempty file. Timeouts include queue time;
51
- caller cancellation and disposal cancel active reads. Keep the source path and Pod target with any
52
- persisted evidence. A successful read describes bytes at that location, not the application's parsed
53
- or effective configuration and not an atomic filesystem snapshot.
54
-
55
- Local files may be symlinks to regular files. Pod reads require `pods/exec` access and `head -c` in the
56
- selected container; paths are passed as argv without a shell. A Pod name is an access location, not
57
- a UID fence; callers needing incarnation guarantees must verify discovery around collection. Pod
58
- sources borrow Kubernetes access from the root manager and never close it themselves. Unsupported
59
- commands, missing paths and denied reads fail explicitly; they are not empty file results.
46
+ Every read reopens all declared paths and returns one ordered result per path. Empty files succeed;
47
+ missing, denied, nonregular and oversized files return individual errors with no partial bytes.
48
+ An error in one file does not discard sibling results. Per-file limits reset for each path; the total
49
+ budget bounds retained decoded bytes, including incomplete files. Queueing and the whole group
50
+ share one deadline. Cancellation, total-budget exhaustion, deadline and transport/protocol failure
51
+ reject the operation. Sources share clients, never previous file contents.
52
+
53
+ Pod groups use one exec and require `sh`, `head -c`, `base64` and `tr`. Paths remain positional
54
+ arguments even when they contain shell syntax or newlines. Indexed base64 records carry raw binary
55
+ bytes plus each file's exit status; truncated or malformed records reject without replay. No remote
56
+ temporary files are written. Local reads allow symlinks to regular files and reject nonregular files.
57
+ Keep the path and Pod target on evidence; grouping guarantees neither an atomic filesystem snapshot
58
+ nor application configuration precedence. A Pod name is not a UID fence; callers needing incarnation
59
+ guarantees must check discovery around collection. File clients borrow root Kubernetes access and
60
+ never close it themselves.
61
+
62
+ Version 0.4.2 requires Common 0.5.4. Replace single path arguments with arrays, supply `maxTotalBytes`,
63
+ and handle per-file results. Publish Common before Toolbox; this change has no single-file legacy adapter.
60
64
 
61
65
  ## Kubernetes resources
62
66
 
@@ -0,0 +1,157 @@
1
+ import {
2
+ BoundedFileClient,
3
+ fileClientOptions,
4
+ filePaths
5
+ } from "./chunk-kkts16w9.js";
6
+ import {
7
+ FileError
8
+ } from "./chunk-20kv4wc3.js";
9
+
10
+ // src/file/pod.ts
11
+ import { posix } from "node:path";
12
+ import { clientKey, FileDataSource } from "@compforge/harness-common";
13
+ var READ_FILES = String.raw`limit=$1
14
+ shift
15
+ index=0
16
+ for path do
17
+ printf '%s ' "$index"
18
+ { if [ -f "$path" ]; then head -c "$limit" "$path" 2>/dev/null; status=$?; else status=1; fi
19
+ printf '\000%03d' "$status"
20
+ } | base64 | tr -d '\r\n'
21
+ printf '\n'
22
+ index=$((index + 1))
23
+ done`;
24
+ function fileRecords(paths, maxBytes, target, sink) {
25
+ let index = 0, header = "", started = false, carry = "", encodedSize = 0, padded = false;
26
+ let trailer = Buffer.alloc(0);
27
+ const maximumEncoded = Math.ceil((maxBytes + 5) / 3) * 4;
28
+ const invalid = () => new FileError("Invalid Pod file group response", { kind: "invalid_response" });
29
+ const body = (part) => {
30
+ if (padded && part)
31
+ throw invalid();
32
+ encodedSize += part.length;
33
+ if (encodedSize > maximumEncoded)
34
+ throw invalid();
35
+ const text = carry + part, end = text.length - text.length % 4;
36
+ carry = text.slice(end);
37
+ if (!end)
38
+ return;
39
+ const encoded = text.slice(0, end), decoded = Buffer.from(encoded, "base64");
40
+ if (decoded.toString("base64") !== encoded)
41
+ throw invalid();
42
+ padded = encoded.endsWith("=");
43
+ if (padded && carry)
44
+ throw invalid();
45
+ const bytes = Buffer.concat([trailer, decoded]);
46
+ if (bytes.length > 4)
47
+ sink.receive(index, bytes.subarray(0, -4));
48
+ trailer = Buffer.from(bytes.subarray(Math.max(0, bytes.length - 4)));
49
+ };
50
+ return {
51
+ receive(bytes) {
52
+ if (bytes.some((byte) => byte > 127))
53
+ throw invalid();
54
+ const text = Buffer.from(bytes).toString("ascii");
55
+ for (let offset = 0;offset < text.length; ) {
56
+ if (!started) {
57
+ const char = text[offset++];
58
+ if (char !== " ") {
59
+ if (!/\d/.test(char) || header.length >= String(paths.length).length)
60
+ throw invalid();
61
+ header += char;
62
+ continue;
63
+ }
64
+ if (index >= paths.length || header !== String(index))
65
+ throw invalid();
66
+ started = true;
67
+ }
68
+ const newline = text.indexOf(`
69
+ `, offset);
70
+ body(text.slice(offset, newline < 0 ? text.length : newline));
71
+ if (newline < 0)
72
+ return;
73
+ if (carry || trailer.length !== 4 || trailer[0] !== 0)
74
+ throw invalid();
75
+ const status = trailer.subarray(1).toString("ascii");
76
+ if (!/^\d{3}$/.test(status) || Number(status) > 255)
77
+ throw invalid();
78
+ sink.complete(index, Number(status) === 0 ? undefined : new FileError(`Cannot read Pod file: ${target.namespace}/${target.pod}/${target.container} ${paths[index]}`, { kind: "operation_failed", code: Number(status) }));
79
+ index++;
80
+ header = "";
81
+ started = false;
82
+ encodedSize = 0;
83
+ padded = false;
84
+ trailer = Buffer.alloc(0);
85
+ offset = newline + 1;
86
+ }
87
+ },
88
+ finish() {
89
+ if (started || header || index !== paths.length)
90
+ throw invalid();
91
+ }
92
+ };
93
+ }
94
+
95
+ class PodFileClient extends BoundedFileClient {
96
+ target;
97
+ acquire;
98
+ #kubernetes;
99
+ constructor(paths, target, options, signal, acquire) {
100
+ super(paths, options, signal);
101
+ this.target = target;
102
+ this.acquire = acquire;
103
+ }
104
+ async initializeAccess() {
105
+ this.#kubernetes = await this.acquire();
106
+ }
107
+ async capture(signal, limits, sink) {
108
+ const abort = new AbortController;
109
+ const combined = AbortSignal.any([signal, abort.signal]);
110
+ const records = fileRecords(this.paths, limits.maxBytes, this.target, sink);
111
+ const result = await this.#kubernetes.exec(this.target.namespace, this.target, ["sh", "-c", READ_FILES, "file-source", String(limits.maxBytes + 1), ...this.paths], {
112
+ signal: combined,
113
+ timeoutMs: limits.timeoutMs,
114
+ collectStdout: false,
115
+ onStdoutBytes: (bytes) => {
116
+ if (combined.aborted)
117
+ return;
118
+ try {
119
+ records.receive(bytes);
120
+ combined.throwIfAborted();
121
+ } catch (error) {
122
+ abort.abort(error);
123
+ }
124
+ }
125
+ });
126
+ combined.throwIfAborted();
127
+ if (!result.ok)
128
+ throw new FileError(`Cannot acquire Pod file group: ${this.target.namespace}/${this.target.pod}/${this.target.container}`, { kind: result.timedOut ? "timeout" : "operation_failed", code: result.exitCode ?? undefined });
129
+ records.finish();
130
+ }
131
+ }
132
+
133
+ class PodFileDataSource extends FileDataSource {
134
+ kubernetes;
135
+ paths;
136
+ target;
137
+ clientKey;
138
+ #options;
139
+ constructor(kubernetes, target, paths, options) {
140
+ super();
141
+ this.kubernetes = kubernetes;
142
+ this.paths = filePaths(paths);
143
+ if (this.paths.some((path) => !posix.isAbsolute(path)))
144
+ throw new FileError("Pod file paths must be absolute", { kind: "invalid_argument" });
145
+ if ([target.namespace, target.pod, target.container].some((value) => !value.trim())) {
146
+ throw new FileError("Pod file namespace, Pod and Container are required", { kind: "invalid_argument" });
147
+ }
148
+ this.target = Object.freeze({ namespace: target.namespace, pod: target.pod, container: target.container });
149
+ this.#options = fileClientOptions(options);
150
+ this.clientKey = clientKey("pod-file", { access: kubernetes.clientKey, target: this.target, paths: this.paths, options: this.#options });
151
+ }
152
+ createClient(clients, signal) {
153
+ return new PodFileClient(this.paths, this.target, this.#options, signal, () => clients.get(this.kubernetes));
154
+ }
155
+ }
156
+
157
+ export { PodFileClient, PodFileDataSource };
@@ -0,0 +1,131 @@
1
+ import {
2
+ FileError,
3
+ ToolboxError
4
+ } from "./chunk-20kv4wc3.js";
5
+ import {
6
+ ConcurrencyPool
7
+ } from "./chunk-v3hxvnrc.js";
8
+
9
+ // src/file/client.ts
10
+ function fileClientOptions(options) {
11
+ if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
12
+ throw new FileError("File concurrency must be a positive integer", { kind: "invalid_argument" });
13
+ }
14
+ return Object.freeze({ concurrency: options.concurrency });
15
+ }
16
+ function filePaths(paths) {
17
+ if (!paths.length || paths.some((path) => typeof path !== "string" || !path || path.includes("\x00")) || new Set(paths).size !== paths.length) {
18
+ throw new FileError("File paths must be nonempty, distinct and contain no NUL", { kind: "invalid_argument" });
19
+ }
20
+ return Object.freeze([...paths]);
21
+ }
22
+
23
+ class BoundedFileClient {
24
+ paths;
25
+ #controller = new AbortController;
26
+ #signal;
27
+ #pool;
28
+ #operations = new Set;
29
+ #ready = false;
30
+ #disposal;
31
+ constructor(paths, options, signal) {
32
+ this.paths = filePaths(paths);
33
+ this.#pool = new ConcurrencyPool(fileClientOptions(options).concurrency);
34
+ this.#signal = AbortSignal.any([signal, this.#controller.signal]);
35
+ }
36
+ async initialize() {
37
+ this.#signal.throwIfAborted();
38
+ await this.initializeAccess();
39
+ this.#signal.throwIfAborted();
40
+ this.#ready = true;
41
+ }
42
+ async initializeAccess() {}
43
+ async read(options) {
44
+ this.#signal.throwIfAborted();
45
+ if (!this.#ready)
46
+ throw new FileError("File client is not initialized", { kind: "operation_failed" });
47
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0 || options.maxBytes >= Number.MAX_SAFE_INTEGER - 5 || !Number.isSafeInteger(options.maxTotalBytes) || options.maxTotalBytes < 0 || !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2147483647) {
48
+ throw new FileError("Invalid file group read limits", { kind: "invalid_argument" });
49
+ }
50
+ const controller = new AbortController;
51
+ const signal = AbortSignal.any([this.#signal, controller.signal, ...options.signal ? [options.signal] : []]);
52
+ const timer = setTimeout(() => controller.abort(new FileError("File group read timed out", { kind: "timeout" })), options.timeoutMs);
53
+ const operation = this.#pool.run(async () => {
54
+ const files = this.paths.map((path) => ({ path, chunks: [], size: 0, done: false, error: undefined }));
55
+ let retained = 0;
56
+ const discard = (file, error) => {
57
+ retained -= file.size;
58
+ file.size = 0;
59
+ file.chunks = [];
60
+ file.error ??= error;
61
+ };
62
+ const entry = (index) => {
63
+ const file = files[index];
64
+ if (!file || file.done)
65
+ throw new FileError("Invalid file group response", { kind: "invalid_response" });
66
+ return file;
67
+ };
68
+ const sink = {
69
+ receive: (index, bytes) => {
70
+ if (signal.aborted)
71
+ return;
72
+ const file = entry(index);
73
+ if (file.error)
74
+ return;
75
+ if (bytes.byteLength > options.maxBytes - file.size) {
76
+ discard(file, new FileError(`File exceeds ${options.maxBytes} bytes: ${file.path}`, { kind: "limit_exceeded" }));
77
+ } else if (bytes.byteLength > options.maxTotalBytes - retained) {
78
+ controller.abort(new FileError(`File group exceeds ${options.maxTotalBytes} bytes`, { kind: "limit_exceeded" }));
79
+ } else {
80
+ file.chunks.push(Uint8Array.from(bytes));
81
+ file.size += bytes.byteLength;
82
+ retained += bytes.byteLength;
83
+ }
84
+ },
85
+ complete: (index, error) => {
86
+ if (signal.aborted)
87
+ return;
88
+ const file = entry(index);
89
+ if (error)
90
+ discard(file, error);
91
+ file.done = true;
92
+ }
93
+ };
94
+ await this.capture(signal, options, sink);
95
+ signal.throwIfAborted();
96
+ if (files.some((file) => !file.done))
97
+ throw new FileError("Incomplete file group response", { kind: "invalid_response" });
98
+ return files.map((file) => {
99
+ if (file.error)
100
+ return { path: file.path, status: "error", error: file.error };
101
+ const bytes = new Uint8Array(file.size);
102
+ let offset = 0;
103
+ for (const chunk of file.chunks) {
104
+ bytes.set(chunk, offset);
105
+ offset += chunk.byteLength;
106
+ }
107
+ return { path: file.path, status: "ok", bytes };
108
+ });
109
+ }, signal);
110
+ this.#operations.add(operation);
111
+ try {
112
+ return await operation;
113
+ } catch (cause) {
114
+ signal.throwIfAborted();
115
+ if (cause instanceof ToolboxError)
116
+ throw cause;
117
+ throw new FileError("Cannot acquire file group", { kind: "operation_failed", cause });
118
+ } finally {
119
+ clearTimeout(timer);
120
+ this.#operations.delete(operation);
121
+ }
122
+ }
123
+ dispose() {
124
+ return this.#disposal ??= (async () => {
125
+ this.#controller.abort(new Error("File client disposed"));
126
+ await Promise.allSettled(this.#operations);
127
+ })();
128
+ }
129
+ }
130
+
131
+ export { fileClientOptions, filePaths, BoundedFileClient };
@@ -0,0 +1,65 @@
1
+ import {
2
+ BoundedFileClient,
3
+ fileClientOptions,
4
+ filePaths
5
+ } from "./chunk-kkts16w9.js";
6
+ import {
7
+ FileError
8
+ } from "./chunk-20kv4wc3.js";
9
+
10
+ // src/file/local.ts
11
+ import { constants } from "node:fs";
12
+ import { open } from "node:fs/promises";
13
+ import { resolve } from "node:path";
14
+ import { clientKey, FileDataSource } from "@compforge/harness-common";
15
+ class LocalFileClient extends BoundedFileClient {
16
+ async capture(signal, limits, sink) {
17
+ for (const [index, path] of this.paths.entries()) {
18
+ try {
19
+ signal.throwIfAborted();
20
+ const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
21
+ try {
22
+ signal.throwIfAborted();
23
+ if (!(await file.stat()).isFile())
24
+ throw new FileError(`Not a regular file: ${path}`, { kind: "unsupported_operation" });
25
+ const stream = file.createReadStream({
26
+ autoClose: false,
27
+ signal,
28
+ end: limits.maxBytes,
29
+ highWaterMark: Math.min(limits.maxBytes + 1, 64 * 1024)
30
+ });
31
+ for await (const bytes of stream)
32
+ sink.receive(index, bytes);
33
+ } finally {
34
+ await file.close();
35
+ }
36
+ sink.complete(index);
37
+ } catch (cause) {
38
+ signal.throwIfAborted();
39
+ const code = cause?.code;
40
+ sink.complete(index, cause instanceof FileError ? cause : new FileError(`Cannot read local file: ${path}`, {
41
+ kind: code === "ENOENT" || code === "ENOTDIR" ? "resource_not_found" : code === "EACCES" || code === "EPERM" ? "permission_denied" : "operation_failed",
42
+ code,
43
+ cause
44
+ }));
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ class LocalFileDataSource extends FileDataSource {
51
+ paths;
52
+ clientKey;
53
+ #options;
54
+ constructor(paths, options) {
55
+ super();
56
+ this.paths = filePaths(filePaths(paths).map((path) => resolve(path)));
57
+ this.#options = fileClientOptions(options);
58
+ this.clientKey = clientKey("local-file", { paths: this.paths, options: this.#options });
59
+ }
60
+ createClient(_clients, signal) {
61
+ return new LocalFileClient(this.paths, this.#options, signal);
62
+ }
63
+ }
64
+
65
+ export { LocalFileClient, LocalFileDataSource };
@@ -1,17 +1,22 @@
1
- import type { FileClient, FileReadOptions } from "@compforge/harness-common";
1
+ import type { FileClient, FileReadOptions, FileReadResult } from "@compforge/harness-common";
2
2
  export interface FileClientOptions {
3
3
  concurrency: number;
4
4
  }
5
+ export interface FileCaptureSink {
6
+ receive(index: number, bytes: Uint8Array): void;
7
+ complete(index: number, error?: Error): void;
8
+ }
5
9
  export declare function fileClientOptions(options: FileClientOptions): Readonly<FileClientOptions>;
6
- /** Shared limits and lifecycle; transports stream bytes and own per-read resource cleanup. */
10
+ export declare function filePaths(paths: readonly string[]): readonly string[];
11
+ /** Group lifecycle and budgets; a per-file failure releases its partial bytes and preserves sibling results. */
7
12
  export declare abstract class BoundedFileClient implements FileClient {
8
13
  #private;
9
- readonly path: string;
10
- constructor(path: string, options: FileClientOptions, signal: AbortSignal);
14
+ readonly paths: readonly string[];
15
+ constructor(paths: readonly string[], options: FileClientOptions, signal: AbortSignal);
11
16
  initialize(): Promise<void>;
12
17
  protected initializeAccess(): Promise<void>;
13
- protected abstract capture(signal: AbortSignal, limits: Pick<FileReadOptions, "maxBytes" | "timeoutMs">, receive: (bytes: Uint8Array) => void): Promise<void>;
14
- /** @spec Limits apply while streaming; a failed read never publishes partial bytes. */
15
- read(options: FileReadOptions): Promise<Uint8Array>;
18
+ protected abstract capture(signal: AbortSignal, limits: FileReadOptions, sink: FileCaptureSink): Promise<void>;
19
+ /** @spec Enforce budgets while streaming; file failures discard only that file, never publish partial bytes. */
20
+ read(options: FileReadOptions): Promise<readonly FileReadResult[]>;
16
21
  dispose(): Promise<void>;
17
22
  }
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  BoundedFileClient,
3
- fileClientOptions
4
- } from "../chunk-mzdz6wwc.js";
3
+ fileClientOptions,
4
+ filePaths
5
+ } from "../chunk-kkts16w9.js";
5
6
  import"../chunk-20kv4wc3.js";
6
7
  import"../chunk-v3hxvnrc.js";
7
8
  export {
9
+ filePaths,
8
10
  fileClientOptions,
9
11
  BoundedFileClient
10
12
  };
@@ -1,4 +1,4 @@
1
- export { FileDataSource, type FileClient, type FileReadOptions } from "@compforge/harness-common";
1
+ export { FileDataSource, type FileClient, type FileReadOptions, type FileReadResult } from "@compforge/harness-common";
2
2
  export { FileError } from "../errors";
3
3
  export type { FileClientOptions } from "./client";
4
4
  export { LocalFileDataSource, LocalFileClient } from "./local";
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  PodFileClient,
3
3
  PodFileDataSource
4
- } from "../chunk-mxyvpy79.js";
4
+ } from "../chunk-grpygk6f.js";
5
5
  import {
6
6
  LocalFileClient,
7
7
  LocalFileDataSource
8
- } from "../chunk-ba883sdp.js";
9
- import"../chunk-mzdz6wwc.js";
8
+ } from "../chunk-sqbew04a.js";
9
+ import"../chunk-kkts16w9.js";
10
10
  import {
11
11
  FileError
12
12
  } from "../chunk-20kv4wc3.js";
@@ -1,13 +1,13 @@
1
1
  import { FileDataSource, type ClientManager, type FileReadOptions } from "@compforge/harness-common";
2
- import { BoundedFileClient, type FileClientOptions } from "./client";
2
+ import { BoundedFileClient, type FileClientOptions, type FileCaptureSink } from "./client";
3
3
  export declare class LocalFileClient extends BoundedFileClient {
4
- protected capture(signal: AbortSignal, limits: Pick<FileReadOptions, "maxBytes" | "timeoutMs">, receive: (bytes: Uint8Array) => void): Promise<void>;
4
+ protected capture(signal: AbortSignal, limits: FileReadOptions, sink: FileCaptureSink): Promise<void>;
5
5
  }
6
- /** @spec Resolve relative paths once at declaration; reads reopen the path to observe replaced mounted files. */
6
+ /** @spec Resolve paths once at declaration; each group read reopens them to observe replaced mounted files. */
7
7
  export declare class LocalFileDataSource extends FileDataSource<LocalFileClient> {
8
8
  #private;
9
- readonly path: string;
9
+ readonly paths: readonly string[];
10
10
  readonly clientKey: string;
11
- constructor(path: string, options: FileClientOptions);
11
+ constructor(paths: readonly string[], options: FileClientOptions);
12
12
  createClient(_clients: Pick<ClientManager, "get">, signal: AbortSignal): LocalFileClient;
13
13
  }
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  LocalFileClient,
3
3
  LocalFileDataSource
4
- } from "../chunk-ba883sdp.js";
5
- import"../chunk-mzdz6wwc.js";
4
+ } from "../chunk-sqbew04a.js";
5
+ import"../chunk-kkts16w9.js";
6
6
  import"../chunk-20kv4wc3.js";
7
7
  import"../chunk-v3hxvnrc.js";
8
8
  export {
@@ -1,6 +1,6 @@
1
1
  import { FileDataSource, type ClientManager, type ClientProvider, type FileReadOptions } from "@compforge/harness-common";
2
2
  import type { KubernetesClient } from "../kubernetes/client";
3
- import { BoundedFileClient, type FileClientOptions } from "./client";
3
+ import { BoundedFileClient, type FileClientOptions, type FileCaptureSink } from "./client";
4
4
  export interface PodFileTarget {
5
5
  namespace: string;
6
6
  pod: string;
@@ -11,21 +11,22 @@ export declare class PodFileClient extends BoundedFileClient {
11
11
  #private;
12
12
  readonly target: Readonly<PodFileTarget>;
13
13
  private readonly acquire;
14
- constructor(path: string, target: Readonly<PodFileTarget>, options: FileClientOptions, signal: AbortSignal, acquire: () => Promise<PodFileAccess>);
14
+ constructor(paths: readonly string[], target: Readonly<PodFileTarget>, options: FileClientOptions, signal: AbortSignal, acquire: () => Promise<PodFileAccess>);
15
15
  protected initializeAccess(): Promise<void>;
16
- protected capture(signal: AbortSignal, limits: Pick<FileReadOptions, "maxBytes" | "timeoutMs">, receive: (bytes: Uint8Array) => void): Promise<void>;
16
+ /** @spec The whole declared group uses exactly one exec; file failures are records, never implicit retries. */
17
+ protected capture(signal: AbortSignal, limits: FileReadOptions, sink: FileCaptureSink): Promise<void>;
17
18
  }
18
19
  /**
19
- * @spec One explicit Pod/container/path per source; workload discovery and replica traversal belong to callers.
20
+ * @spec One explicit Pod/container and one caller-declared acquisition group; discovery belongs to consumers.
20
21
  * @rule Borrow Kubernetes access from the root manager; file clients never dispose that shared dependency.
21
22
  * @see {@link component://../../../docs/toolbox.md}
22
23
  */
23
24
  export declare class PodFileDataSource extends FileDataSource<PodFileClient> {
24
25
  #private;
25
26
  private readonly kubernetes;
26
- readonly path: string;
27
+ readonly paths: readonly string[];
27
28
  readonly target: Readonly<PodFileTarget>;
28
29
  readonly clientKey: string;
29
- constructor(kubernetes: ClientProvider<PodFileAccess>, target: PodFileTarget, path: string, options: FileClientOptions);
30
+ constructor(kubernetes: ClientProvider<PodFileAccess>, target: PodFileTarget, paths: readonly string[], options: FileClientOptions);
30
31
  createClient(clients: Pick<ClientManager, "get">, signal: AbortSignal): PodFileClient;
31
32
  }
package/dist/file/pod.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  PodFileClient,
3
3
  PodFileDataSource
4
- } from "../chunk-mxyvpy79.js";
5
- import"../chunk-mzdz6wwc.js";
4
+ } from "../chunk-grpygk6f.js";
5
+ import"../chunk-kkts16w9.js";
6
6
  import"../chunk-20kv4wc3.js";
7
7
  import"../chunk-v3hxvnrc.js";
8
8
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compforge/harness-toolbox",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -99,7 +99,7 @@
99
99
  "README.md"
100
100
  ],
101
101
  "dependencies": {
102
- "@compforge/harness-common": "0.5.3",
102
+ "@compforge/harness-common": "0.5.4",
103
103
  "@aws-sdk/client-s3": "3.1133.0",
104
104
  "@kubernetes/client-node": "1.4.0",
105
105
  "@opensearch-project/opensearch": "3.6.0",
@@ -1,60 +0,0 @@
1
- import {
2
- BoundedFileClient,
3
- fileClientOptions
4
- } from "./chunk-mzdz6wwc.js";
5
- import {
6
- FileError
7
- } from "./chunk-20kv4wc3.js";
8
-
9
- // src/file/local.ts
10
- import { constants } from "node:fs";
11
- import { open } from "node:fs/promises";
12
- import { resolve } from "node:path";
13
- import { clientKey, FileDataSource } from "@compforge/harness-common";
14
- class LocalFileClient extends BoundedFileClient {
15
- async capture(signal, limits, receive) {
16
- try {
17
- const file = await open(this.path, constants.O_RDONLY | constants.O_NONBLOCK);
18
- try {
19
- signal.throwIfAborted();
20
- if (!(await file.stat()).isFile()) {
21
- throw new FileError(`Not a regular file: ${this.path}`, { kind: "unsupported_operation" });
22
- }
23
- const stream = file.createReadStream({ autoClose: false, signal, highWaterMark: Math.min(limits.maxBytes + 1, 64 * 1024) });
24
- for await (const bytes of stream)
25
- receive(bytes);
26
- } finally {
27
- await file.close();
28
- }
29
- } catch (cause) {
30
- signal.throwIfAborted();
31
- if (cause instanceof FileError)
32
- throw cause;
33
- const code = cause?.code;
34
- throw new FileError(`Cannot read local file: ${this.path}`, {
35
- kind: code === "ENOENT" || code === "ENOTDIR" ? "resource_not_found" : code === "EACCES" || code === "EPERM" ? "permission_denied" : "operation_failed",
36
- code,
37
- cause
38
- });
39
- }
40
- }
41
- }
42
-
43
- class LocalFileDataSource extends FileDataSource {
44
- path;
45
- clientKey;
46
- #options;
47
- constructor(path, options) {
48
- super();
49
- if (!path || path.includes("\x00"))
50
- throw new FileError("File path must be nonempty and contain no NUL", { kind: "invalid_argument" });
51
- this.path = resolve(path);
52
- this.#options = fileClientOptions(options);
53
- this.clientKey = clientKey("local-file", { path: this.path, options: this.#options });
54
- }
55
- createClient(_clients, signal) {
56
- return new LocalFileClient(this.path, this.#options, signal);
57
- }
58
- }
59
-
60
- export { LocalFileClient, LocalFileDataSource };
@@ -1,62 +0,0 @@
1
- import {
2
- BoundedFileClient,
3
- fileClientOptions
4
- } from "./chunk-mzdz6wwc.js";
5
- import {
6
- FileError
7
- } from "./chunk-20kv4wc3.js";
8
-
9
- // src/file/pod.ts
10
- import { posix } from "node:path";
11
- import { clientKey, FileDataSource } from "@compforge/harness-common";
12
- class PodFileClient extends BoundedFileClient {
13
- target;
14
- acquire;
15
- #kubernetes;
16
- constructor(path, target, options, signal, acquire) {
17
- super(path, options, signal);
18
- this.target = target;
19
- this.acquire = acquire;
20
- }
21
- async initializeAccess() {
22
- this.#kubernetes = await this.acquire();
23
- }
24
- async capture(signal, limits, receive) {
25
- const result = await this.#kubernetes.exec(this.target.namespace, this.target, ["head", "-c", String(limits.maxBytes + 1), this.path], { signal, timeoutMs: limits.timeoutMs, collectStdout: false, onStdoutBytes: receive });
26
- signal.throwIfAborted();
27
- if (!result.ok)
28
- throw new FileError(`Cannot read Pod file: ${this.target.namespace}/${this.target.pod}/${this.target.container} ${this.path}`, { kind: result.timedOut ? "timeout" : "operation_failed", code: result.exitCode ?? undefined });
29
- }
30
- }
31
-
32
- class PodFileDataSource extends FileDataSource {
33
- kubernetes;
34
- path;
35
- target;
36
- clientKey;
37
- #options;
38
- constructor(kubernetes, target, path, options) {
39
- super();
40
- this.kubernetes = kubernetes;
41
- if (!posix.isAbsolute(path) || path.includes("\x00")) {
42
- throw new FileError("Pod file path must be absolute and contain no NUL", { kind: "invalid_argument" });
43
- }
44
- if ([target.namespace, target.pod, target.container].some((value) => !value.trim())) {
45
- throw new FileError("Pod file namespace, Pod and Container are required", { kind: "invalid_argument" });
46
- }
47
- this.path = path;
48
- this.target = Object.freeze({ namespace: target.namespace, pod: target.pod, container: target.container });
49
- this.#options = fileClientOptions(options);
50
- this.clientKey = clientKey("pod-file", {
51
- access: kubernetes.clientKey,
52
- target: this.target,
53
- path: this.path,
54
- options: this.#options
55
- });
56
- }
57
- createClient(clients, signal) {
58
- return new PodFileClient(this.path, this.target, this.#options, signal, () => clients.get(this.kubernetes));
59
- }
60
- }
61
-
62
- export { PodFileClient, PodFileDataSource };
@@ -1,91 +0,0 @@
1
- import {
2
- FileError,
3
- ToolboxError
4
- } from "./chunk-20kv4wc3.js";
5
- import {
6
- ConcurrencyPool
7
- } from "./chunk-v3hxvnrc.js";
8
-
9
- // src/file/client.ts
10
- function fileClientOptions(options) {
11
- if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
12
- throw new FileError("File concurrency must be a positive integer", { kind: "invalid_argument" });
13
- }
14
- return Object.freeze({ concurrency: options.concurrency });
15
- }
16
-
17
- class BoundedFileClient {
18
- path;
19
- #controller = new AbortController;
20
- #signal;
21
- #pool;
22
- #operations = new Set;
23
- #ready = false;
24
- #disposal;
25
- constructor(path, options, signal) {
26
- this.path = path;
27
- this.#pool = new ConcurrencyPool(fileClientOptions(options).concurrency);
28
- this.#signal = AbortSignal.any([signal, this.#controller.signal]);
29
- }
30
- async initialize() {
31
- this.#signal.throwIfAborted();
32
- await this.initializeAccess();
33
- this.#signal.throwIfAborted();
34
- this.#ready = true;
35
- }
36
- async initializeAccess() {}
37
- async read(options) {
38
- this.#signal.throwIfAborted();
39
- if (!this.#ready)
40
- throw new FileError(`File client is not initialized: ${this.path}`, { kind: "operation_failed" });
41
- if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0 || options.maxBytes === Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2147483647) {
42
- throw new FileError(`Invalid file read limits: ${this.path}`, { kind: "invalid_argument" });
43
- }
44
- const { maxBytes, timeoutMs } = options;
45
- const controller = new AbortController;
46
- const signal = AbortSignal.any([this.#signal, controller.signal, ...options.signal ? [options.signal] : []]);
47
- const timer = setTimeout(() => controller.abort(new FileError(`File read timed out: ${this.path}`, { kind: "timeout" })), timeoutMs);
48
- const operation = this.#pool.run(async () => {
49
- const chunks = [];
50
- let size = 0;
51
- await this.capture(signal, { maxBytes, timeoutMs }, (bytes) => {
52
- if (signal.aborted)
53
- return;
54
- if (bytes.byteLength > maxBytes - size) {
55
- controller.abort(new FileError(`File exceeds ${maxBytes} bytes: ${this.path}`, { kind: "limit_exceeded" }));
56
- return;
57
- }
58
- chunks.push(Uint8Array.from(bytes));
59
- size += bytes.byteLength;
60
- });
61
- signal.throwIfAborted();
62
- const result = new Uint8Array(size);
63
- let offset = 0;
64
- for (const chunk of chunks) {
65
- result.set(chunk, offset);
66
- offset += chunk.byteLength;
67
- }
68
- return result;
69
- }, signal);
70
- this.#operations.add(operation);
71
- try {
72
- return await operation;
73
- } catch (cause) {
74
- signal.throwIfAborted();
75
- if (cause instanceof ToolboxError)
76
- throw cause;
77
- throw new FileError(`Cannot read file: ${this.path}`, { kind: "operation_failed", cause });
78
- } finally {
79
- clearTimeout(timer);
80
- this.#operations.delete(operation);
81
- }
82
- }
83
- dispose() {
84
- return this.#disposal ??= (async () => {
85
- this.#controller.abort(new Error(`File client disposed: ${this.path}`));
86
- await Promise.allSettled(this.#operations);
87
- })();
88
- }
89
- }
90
-
91
- export { fileClientOptions, BoundedFileClient };