@tikoci/centrs 0.1.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.
Files changed (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Group fanout for `retrieve`.
3
+ *
4
+ * A `--group <name>` selector expands a CDB group into N targets and runs each
5
+ * through the single-target retrieve tail (`runResolvedRetrieve`), collecting a
6
+ * per-target inner `CentrsEnvelope` into the LOCKED `FanoutData` shape from
7
+ * `src/core/envelope.ts`. The outer envelope's `ok` means the orchestration
8
+ * produced a complete per-target result set; a per-target failure is an INNER
9
+ * `ok: false` envelope, never an outer failure and never a thrown error out of
10
+ * the batch.
11
+ *
12
+ * Design rules implemented here (see `commands/retrieve/README.md`):
13
+ * - The CDB is loaded + decrypted ONCE for the whole fanout (group expansion).
14
+ * - Each target is resolved + validated independently (validation is
15
+ * per-target via live `/console/inspect`; schemas differ by version).
16
+ * - Concurrency is a bounded worker pool; defaults are transport-aware
17
+ * (REST 8, native-api 4) and overridable with `--concurrency`.
18
+ * - Transient drops are retried with jittered backoff on an allowlisted
19
+ * retryable-code set only; router-side and client-side errors are not.
20
+ * - `data.targets[]` is ordered by CDB `recordIndex` regardless of completion
21
+ * order; `summary` counts are derived from inner `ok`.
22
+ *
23
+ * Outer `ok: false` is reserved for fanout failing BEFORE reliable per-target
24
+ * results exist (bad request shape, CDB decrypt failure); those throw out of
25
+ * {@link retrieveGroup} and the CLI renders them with
26
+ * {@link buildRetrieveFanoutErrorEnvelope}.
27
+ */
28
+
29
+ import type {
30
+ CentrsErrorEnvelope,
31
+ CentrsSuccessEnvelope,
32
+ FanoutData,
33
+ FanoutSummary,
34
+ } from "./core/envelope.ts";
35
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
36
+ import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
37
+ import {
38
+ type CdbGroupExpansion,
39
+ type CdbGroupResolveInput,
40
+ type CdbResolution,
41
+ expandCdbGroup,
42
+ } from "./resolver/index.ts";
43
+ import {
44
+ buildResolvedRetrieve,
45
+ buildRetrieveErrorEnvelopeFromResolved,
46
+ type ResolvedRetrieveRequest,
47
+ type RetrieveEnvelope,
48
+ type RetrieveOperationMeta,
49
+ type RetrieveOutputFormat,
50
+ type RetrieveRequest,
51
+ type RetrieveRequestSummary,
52
+ type RetrieveSuccessEnvelope,
53
+ resolveMacForRetrieve,
54
+ resolveRetrieveGlobalContext,
55
+ runResolvedRetrieve,
56
+ toYaml,
57
+ validateRetrieveRequestShape,
58
+ } from "./retrieve.ts";
59
+
60
+ /** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
61
+ export const RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS: Record<
62
+ "rest-api" | "native-api",
63
+ number
64
+ > = {
65
+ "rest-api": 8,
66
+ "native-api": 4,
67
+ };
68
+
69
+ /** Extra attempts after the first try (jittered backoff between them). */
70
+ export const RETRIEVE_FANOUT_MAX_RETRIES = 2;
71
+
72
+ /** Base backoff in milliseconds; doubled per attempt with up to +base jitter. */
73
+ export const RETRIEVE_FANOUT_BACKOFF_BASE_MS = 200;
74
+
75
+ /** RouterOS-style codes that are safe to retry under fanout. */
76
+ export const RETRIEVE_FANOUT_RETRYABLE_CODES = [
77
+ "transport/network",
78
+ "transport/connection-closed",
79
+ ] as const;
80
+
81
+ export interface RetrieveFanoutOperationMeta {
82
+ kind: "fanout";
83
+ group: string;
84
+ concurrency: number;
85
+ summary: FanoutSummary;
86
+ request: RetrieveRequestSummary;
87
+ }
88
+
89
+ export type RetrieveFanoutData = FanoutData<unknown, RetrieveOperationMeta>;
90
+ export type RetrieveFanoutEnvelope = CentrsSuccessEnvelope<
91
+ RetrieveFanoutData,
92
+ RetrieveFanoutOperationMeta
93
+ >;
94
+ export type RetrieveFanoutErrorEnvelope =
95
+ CentrsErrorEnvelope<RetrieveFanoutOperationMeta>;
96
+
97
+ /** Test/override seams; production callers pass nothing. */
98
+ export interface RetrieveGroupInternals {
99
+ /** Per-target executor (attempts; throws on failure). Defaults to the live tail. */
100
+ execute?: (
101
+ resolved: ResolvedRetrieveRequest,
102
+ recordIndex: number,
103
+ ) => Promise<RetrieveSuccessEnvelope>;
104
+ /** Group expansion. Defaults to the real CDB-backed expansion. */
105
+ expand?: (
106
+ input: CdbGroupResolveInput,
107
+ env: Record<string, string | undefined>,
108
+ ) => Promise<CdbGroupExpansion>;
109
+ /** Backoff sleeper. Defaults to a real timer; tests pass a no-op. */
110
+ sleep?: (ms: number) => Promise<void>;
111
+ }
112
+
113
+ /**
114
+ * Run a `retrieve` group fanout. Always returns a success envelope when the
115
+ * orchestration completes (even if every target failed); throws only for
116
+ * pre-flight failures (bad request shape, CDB decrypt) the caller renders as an
117
+ * outer error.
118
+ */
119
+ export async function retrieveGroup(
120
+ request: RetrieveRequest,
121
+ env: Record<string, string | undefined> = Bun.env,
122
+ internals: RetrieveGroupInternals = {},
123
+ ): Promise<RetrieveFanoutEnvelope> {
124
+ const group = request.group;
125
+ if (group === undefined || group.length === 0) {
126
+ throw new CentrsError({
127
+ code: "usage/missing-group",
128
+ summary: "retrieve group fanout requires a non-empty `--group` value.",
129
+ remediation:
130
+ "Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
131
+ });
132
+ }
133
+
134
+ const attributeSelections = validateRetrieveRequestShape(request);
135
+ const global = resolveRetrieveGlobalContext(
136
+ request,
137
+ env,
138
+ attributeSelections,
139
+ );
140
+ if (request.concurrency !== undefined) {
141
+ resolveFanoutConcurrency(request.concurrency, global.via);
142
+ }
143
+
144
+ const expand = internals.expand ?? expandCdbGroup;
145
+ const expansion = await expand(
146
+ {
147
+ group,
148
+ cdbFile: request.cdbFile,
149
+ cdbPassword: request.cdbPassword,
150
+ },
151
+ env,
152
+ );
153
+
154
+ const concurrencyVia = resolveFanoutConcurrencyProtocol(
155
+ request,
156
+ env,
157
+ global.via,
158
+ expansion,
159
+ );
160
+ const concurrency = resolveFanoutConcurrency(
161
+ request.concurrency,
162
+ concurrencyVia,
163
+ );
164
+
165
+ if (expansion.empty) {
166
+ return buildFanoutEnvelope({
167
+ group,
168
+ concurrency,
169
+ summary: { total: 0, ok: 0, failed: 0 },
170
+ requestSummary: global.summary,
171
+ settings: global.settings,
172
+ via: global.via,
173
+ warnings: expansion.warnings,
174
+ targets: [],
175
+ });
176
+ }
177
+
178
+ const execute = internals.execute ?? runResolvedRetrieve;
179
+ const sleep = internals.sleep ?? defaultSleep;
180
+
181
+ const targets = await runBoundedPool(
182
+ expansion.targets,
183
+ concurrency,
184
+ async (member): Promise<RetrieveEnvelope> => {
185
+ let resolved: ResolvedRetrieveRequest;
186
+ try {
187
+ const macResolution = await resolveMacForRetrieve(
188
+ request,
189
+ env,
190
+ member.resolution,
191
+ );
192
+ resolved = buildResolvedRetrieve(
193
+ request,
194
+ env,
195
+ member.resolution,
196
+ attributeSelections,
197
+ macResolution,
198
+ );
199
+ } catch (error) {
200
+ return buildResolveFailureEnvelope(member.resolution, error);
201
+ }
202
+ return runTargetWithRetry(resolved, member.recordIndex, execute, sleep);
203
+ },
204
+ );
205
+
206
+ const summary = summarizeFanout(targets);
207
+ return buildFanoutEnvelope({
208
+ group,
209
+ concurrency,
210
+ summary,
211
+ requestSummary: global.summary,
212
+ settings: global.settings,
213
+ via: commonVia(targets),
214
+ warnings: expansion.warnings,
215
+ targets,
216
+ });
217
+ }
218
+
219
+ /** Outer error envelope for a fanout that failed before per-target results. */
220
+ export function buildRetrieveFanoutErrorEnvelope(
221
+ request: RetrieveRequest,
222
+ error: unknown,
223
+ ): RetrieveFanoutErrorEnvelope {
224
+ const centrsError =
225
+ error instanceof CentrsError
226
+ ? error
227
+ : new CentrsError({
228
+ code: "internal/unhandled",
229
+ summary: "retrieve group fanout failed with an unexpected error.",
230
+ remediation:
231
+ "Re-run with `--format json` to capture the structured error details for debugging.",
232
+ cause: error,
233
+ });
234
+
235
+ const requestedVia = plannedProtocols.includes(
236
+ request.via as RouterOsProtocol,
237
+ )
238
+ ? (request.via as RouterOsProtocol)
239
+ : null;
240
+
241
+ return {
242
+ ok: false,
243
+ error: serializeCentrsError(centrsError),
244
+ warnings: [],
245
+ tips: [],
246
+ meta: {
247
+ target: {},
248
+ via: requestedVia,
249
+ settings: {},
250
+ },
251
+ };
252
+ }
253
+
254
+ /** Resolve and validate the worker-pool size. */
255
+ export function resolveFanoutConcurrency(
256
+ requested: number | undefined,
257
+ via: RouterOsProtocol,
258
+ ): number {
259
+ if (requested !== undefined) {
260
+ if (!Number.isInteger(requested) || requested < 1) {
261
+ throw new CentrsError({
262
+ code: "usage/invalid-concurrency",
263
+ summary: `--concurrency must be an integer >= 1. Received: ${requested}`,
264
+ remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
265
+ context: { concurrency: requested },
266
+ });
267
+ }
268
+ return requested;
269
+ }
270
+ if (via === "native-api") {
271
+ return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["native-api"];
272
+ }
273
+ return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["rest-api"];
274
+ }
275
+
276
+ function resolveFanoutConcurrencyProtocol(
277
+ request: RetrieveRequest,
278
+ env: Record<string, string | undefined>,
279
+ globalVia: RouterOsProtocol,
280
+ expansion: CdbGroupExpansion,
281
+ ): RouterOsProtocol {
282
+ if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
283
+ return globalVia;
284
+ }
285
+ if (
286
+ expansion.targets.some(
287
+ (target) => target.resolution.overrides.via?.value === "native-api",
288
+ )
289
+ ) {
290
+ return "native-api";
291
+ }
292
+ return globalVia;
293
+ }
294
+
295
+ /**
296
+ * Classify a per-target failure as retryable. Only the locked allowlist
297
+ * retries: generic transport/network failures and dropped/closed connections
298
+ * (including REST 5xx mapped to `transport/connection-closed`). Router-side
299
+ * (`routeros/*`), validation, auth, cdb, target, input, usage, timeout, TLS,
300
+ * connection-refused, and DNS failures are deterministic and never retried.
301
+ */
302
+ export function isRetryableFanoutError(error: unknown): boolean {
303
+ if (!(error instanceof CentrsError)) {
304
+ return false;
305
+ }
306
+ return (RETRIEVE_FANOUT_RETRYABLE_CODES as readonly string[]).includes(
307
+ error.code,
308
+ );
309
+ }
310
+
311
+ export function summarizeFanout(
312
+ targets: readonly RetrieveEnvelope[],
313
+ ): FanoutSummary {
314
+ let ok = 0;
315
+ let failed = 0;
316
+ for (const target of targets) {
317
+ if (target.ok) {
318
+ ok += 1;
319
+ } else {
320
+ failed += 1;
321
+ }
322
+ }
323
+ return { total: targets.length, ok, failed };
324
+ }
325
+
326
+ async function runTargetWithRetry(
327
+ resolved: ResolvedRetrieveRequest,
328
+ recordIndex: number,
329
+ execute: (
330
+ resolved: ResolvedRetrieveRequest,
331
+ recordIndex: number,
332
+ ) => Promise<RetrieveSuccessEnvelope>,
333
+ sleep: (ms: number) => Promise<void>,
334
+ ): Promise<RetrieveEnvelope> {
335
+ let attempt = 0;
336
+ for (;;) {
337
+ try {
338
+ return await execute(resolved, recordIndex);
339
+ } catch (error) {
340
+ if (
341
+ attempt < RETRIEVE_FANOUT_MAX_RETRIES &&
342
+ isRetryableFanoutError(error)
343
+ ) {
344
+ attempt += 1;
345
+ await sleep(backoffMs(attempt));
346
+ continue;
347
+ }
348
+ return buildRetrieveErrorEnvelopeFromResolved(resolved, error);
349
+ }
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Ordered bounded worker pool. Runs `worker` over `items` with at most
355
+ * `concurrency` in flight, returning results in INPUT order regardless of
356
+ * completion order.
357
+ */
358
+ export async function runBoundedPool<I, O>(
359
+ items: readonly I[],
360
+ concurrency: number,
361
+ worker: (item: I, index: number) => Promise<O>,
362
+ ): Promise<O[]> {
363
+ const results = new Array<O>(items.length);
364
+ let next = 0;
365
+ const width = Math.max(1, Math.min(concurrency, items.length));
366
+
367
+ async function drain(): Promise<void> {
368
+ for (;;) {
369
+ const index = next;
370
+ next += 1;
371
+ if (index >= items.length) {
372
+ return;
373
+ }
374
+ results[index] = await worker(items[index] as I, index);
375
+ }
376
+ }
377
+
378
+ await Promise.all(Array.from({ length: width }, () => drain()));
379
+ return results;
380
+ }
381
+
382
+ function buildResolveFailureEnvelope(
383
+ resolution: CdbResolution,
384
+ error: unknown,
385
+ ): RetrieveEnvelope {
386
+ const centrsError =
387
+ error instanceof CentrsError
388
+ ? error
389
+ : new CentrsError({
390
+ code: "internal/unhandled",
391
+ summary: "Failed to resolve a fanout target.",
392
+ cause: error,
393
+ });
394
+ return {
395
+ ok: false,
396
+ error: serializeCentrsError(centrsError),
397
+ warnings: [...resolution.warnings],
398
+ tips: [],
399
+ meta: {
400
+ target: {
401
+ input: resolution.identity,
402
+ identity: resolution.identity,
403
+ recordIndex: resolution.recordIndex,
404
+ },
405
+ via: null,
406
+ settings: {},
407
+ },
408
+ };
409
+ }
410
+
411
+ interface FanoutEnvelopeInput {
412
+ group: string;
413
+ concurrency: number;
414
+ summary: FanoutSummary;
415
+ requestSummary: RetrieveRequestSummary;
416
+ settings: RetrieveFanoutEnvelope["meta"]["settings"];
417
+ via: RouterOsProtocol | null;
418
+ warnings: RetrieveFanoutEnvelope["warnings"];
419
+ targets: readonly RetrieveEnvelope[];
420
+ }
421
+
422
+ function buildFanoutEnvelope(
423
+ input: FanoutEnvelopeInput,
424
+ ): RetrieveFanoutEnvelope {
425
+ return {
426
+ ok: true,
427
+ data: {
428
+ summary: input.summary,
429
+ targets: input.targets,
430
+ },
431
+ warnings: input.warnings,
432
+ tips: [],
433
+ meta: {
434
+ target: {},
435
+ via: input.via,
436
+ settings: input.settings,
437
+ operation: {
438
+ kind: "fanout",
439
+ group: input.group,
440
+ concurrency: input.concurrency,
441
+ summary: input.summary,
442
+ request: input.requestSummary,
443
+ },
444
+ },
445
+ };
446
+ }
447
+
448
+ function commonVia(
449
+ targets: readonly RetrieveEnvelope[],
450
+ ): RouterOsProtocol | null {
451
+ let via: RouterOsProtocol | null | undefined;
452
+ for (const target of targets) {
453
+ const candidate = target.meta.via;
454
+ if (via === undefined) {
455
+ via = candidate;
456
+ } else if (via !== candidate) {
457
+ return null;
458
+ }
459
+ }
460
+ return via ?? null;
461
+ }
462
+
463
+ function backoffMs(attempt: number): number {
464
+ const base = RETRIEVE_FANOUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
465
+ return base + Math.floor(Math.random() * RETRIEVE_FANOUT_BACKOFF_BASE_MS);
466
+ }
467
+
468
+ function defaultSleep(ms: number): Promise<void> {
469
+ return new Promise((resolve) => setTimeout(resolve, ms));
470
+ }
471
+
472
+ /**
473
+ * Render a fanout envelope. `json`/`yaml` serialize the whole envelope (lossless
474
+ * against the JSON shape); `text` renders a fanout summary line plus one line
475
+ * per target ordered by record index.
476
+ */
477
+ export function renderRetrieveFanoutEnvelope(
478
+ envelope: RetrieveFanoutEnvelope | RetrieveFanoutErrorEnvelope,
479
+ format: RetrieveOutputFormat,
480
+ options: { verbose?: boolean } = {},
481
+ ): string {
482
+ switch (format) {
483
+ case "json":
484
+ return JSON.stringify(envelope, null, 2);
485
+ case "yaml":
486
+ return toYaml(envelope);
487
+ default:
488
+ return renderFanoutText(envelope, options);
489
+ }
490
+ }
491
+
492
+ function renderFanoutText(
493
+ envelope: RetrieveFanoutEnvelope | RetrieveFanoutErrorEnvelope,
494
+ options: { verbose?: boolean },
495
+ ): string {
496
+ if (!envelope.ok) {
497
+ const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
498
+ if (envelope.error.remediation) {
499
+ lines.push(`Fix: ${envelope.error.remediation}`);
500
+ }
501
+ return lines.join("\n");
502
+ }
503
+
504
+ const operation = envelope.meta.operation;
505
+ const { total, ok, failed } = envelope.data.summary;
506
+ const lines: string[] = [
507
+ `group ${operation?.group ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
508
+ ];
509
+
510
+ for (const target of envelope.data.targets) {
511
+ const meta = target.meta;
512
+ const label = meta.target.identity ?? meta.target.host ?? "(unknown)";
513
+ const index = meta.target.recordIndex ?? "-";
514
+ if (target.ok) {
515
+ lines.push(
516
+ ` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
517
+ );
518
+ } else {
519
+ lines.push(
520
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
521
+ );
522
+ }
523
+ if (options.verbose && !target.ok && target.error.remediation) {
524
+ lines.push(` Fix: ${target.error.remediation}`);
525
+ }
526
+ }
527
+
528
+ for (const warning of envelope.warnings) {
529
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
530
+ }
531
+
532
+ return lines.join("\n");
533
+ }