@tikoci/centrs 0.1.1 → 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -1,28 +1,33 @@
1
1
  /**
2
- * Group fanout for `retrieve`.
2
+ * Multi-target fan-out for `retrieve`.
3
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
4
+ * A target selection (`--group` / `--where` / `--all` / `--default` / multiple
5
+ * positionals) expands to N members and runs each through the single-target
6
+ * retrieve tail (`buildResolvedRetrieve` → `runResolvedRetrieve`), collecting a
6
7
  * per-target inner `CentrsEnvelope` into the LOCKED `FanoutData` shape from
7
8
  * `src/core/envelope.ts`. The outer envelope's `ok` means the orchestration
8
9
  * produced a complete per-target result set; a per-target failure is an INNER
9
10
  * `ok: false` envelope, never an outer failure and never a thrown error out of
10
11
  * the batch.
11
12
  *
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).
13
+ * Boundaries (see `commands/retrieve/README.md`, `docs/CONSTITUTION.md`), shared
14
+ * with `api-fanout.ts`:
15
+ * - The CDB is loaded + decrypted ONCE (`expandCdbSelection`); a cdb member is
16
+ * resolved with its pre-resolved record, so `resolveCdb`'s `__default__`
17
+ * synthetic fallback never collides ad-hoc literals.
18
+ * - A `--default` / `__default__` member is guarded from being dialed as the
19
+ * literal hostname `"__default__"` — it fails that one target deterministically
20
+ * with `target/unresolved`.
21
+ * - Each target is resolved + validated independently (validation is per-target
22
+ * via live `/console/inspect`; schemas differ by version).
16
23
  * - Concurrency is a bounded worker pool; defaults are transport-aware
17
24
  * (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`.
25
+ * - `data.targets[]` is ordered by CDB `recordIndex` (literals appended in
26
+ * positional order); `summary` counts are derived from inner `ok`.
22
27
  *
23
- * Outer `ok: false` is reserved for fanout failing BEFORE reliable per-target
28
+ * Outer `ok: false` is reserved for fan-out failing BEFORE reliable per-target
24
29
  * results exist (bad request shape, CDB decrypt failure); those throw out of
25
- * {@link retrieveGroup} and the CLI renders them with
30
+ * {@link retrieveFanout} and the CLI renders them with
26
31
  * {@link buildRetrieveFanoutErrorEnvelope}.
27
32
  */
28
33
 
@@ -32,13 +37,25 @@ import type {
32
37
  FanoutData,
33
38
  FanoutSummary,
34
39
  } from "./core/envelope.ts";
40
+ import {
41
+ buildFanoutEnvelope,
42
+ buildFanoutResolveFailure,
43
+ commonVia,
44
+ defaultFanoutSleep,
45
+ resolveFanoutConcurrency,
46
+ runFanout,
47
+ summarizeFanout,
48
+ } from "./core/fanout.ts";
35
49
  import { CentrsError, serializeCentrsError } from "./errors.ts";
36
50
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
37
51
  import {
38
- type CdbGroupExpansion,
39
- type CdbGroupResolveInput,
40
- type CdbResolution,
41
- expandCdbGroup,
52
+ type CdbSelectionExpansion,
53
+ type CdbSelectionMember,
54
+ type CdbSelectionResolveInput,
55
+ expandCdbSelection,
56
+ isDefaultRecordTarget,
57
+ loadEnvFileDefaults,
58
+ type TargetSelection,
42
59
  } from "./resolver/index.ts";
43
60
  import {
44
61
  buildResolvedRetrieve,
@@ -52,38 +69,27 @@ import {
52
69
  type RetrieveSuccessEnvelope,
53
70
  resolveMacForRetrieve,
54
71
  resolveRetrieveGlobalContext,
72
+ resolveRetrieveRequest,
55
73
  runResolvedRetrieve,
56
74
  toYaml,
57
75
  validateRetrieveRequestShape,
58
76
  } from "./retrieve.ts";
59
77
 
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;
78
+ /** Compact description of the selector that produced the fan-out. */
79
+ export interface RetrieveSelectionSummary {
80
+ groups: readonly string[];
81
+ where: readonly string[];
82
+ all: boolean;
83
+ default: boolean;
84
+ positionals: readonly string[];
85
+ }
80
86
 
81
87
  export interface RetrieveFanoutOperationMeta {
82
88
  kind: "fanout";
83
- group: string;
84
89
  concurrency: number;
85
90
  summary: FanoutSummary;
86
91
  request: RetrieveRequestSummary;
92
+ selection: RetrieveSelectionSummary;
87
93
  }
88
94
 
89
95
  export type RetrieveFanoutData = FanoutData<unknown, RetrieveOperationMeta>;
@@ -95,60 +101,109 @@ export type RetrieveFanoutErrorEnvelope =
95
101
  CentrsErrorEnvelope<RetrieveFanoutOperationMeta>;
96
102
 
97
103
  /** Test/override seams; production callers pass nothing. */
98
- export interface RetrieveGroupInternals {
104
+ export interface RetrieveFanoutInternals {
99
105
  /** Per-target executor (attempts; throws on failure). Defaults to the live tail. */
100
106
  execute?: (
101
107
  resolved: ResolvedRetrieveRequest,
102
- recordIndex: number,
103
108
  ) => Promise<RetrieveSuccessEnvelope>;
104
- /** Group expansion. Defaults to the real CDB-backed expansion. */
109
+ /** Selection expansion. Defaults to the real CDB-backed expansion. */
105
110
  expand?: (
106
- input: CdbGroupResolveInput,
111
+ selection: TargetSelection,
112
+ input: CdbSelectionResolveInput,
107
113
  env: Record<string, string | undefined>,
108
- ) => Promise<CdbGroupExpansion>;
114
+ config?: Record<string, string | undefined>,
115
+ ) => Promise<CdbSelectionExpansion>;
109
116
  /** Backoff sleeper. Defaults to a real timer; tests pass a no-op. */
110
117
  sleep?: (ms: number) => Promise<void>;
111
118
  }
112
119
 
120
+ export interface RetrieveFanoutOptions {
121
+ concurrency?: number;
122
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
123
+ allowAdhoc?: boolean;
124
+ }
125
+
126
+ function summarizeSelection(
127
+ selection: TargetSelection,
128
+ ): RetrieveSelectionSummary {
129
+ return {
130
+ groups: selection.groups,
131
+ where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
132
+ all: selection.all,
133
+ default: selection.default,
134
+ positionals: selection.positionals,
135
+ };
136
+ }
137
+
138
+ function memberTargetMeta(member: CdbSelectionMember): {
139
+ input?: string;
140
+ identity?: string;
141
+ recordIndex?: number;
142
+ } {
143
+ if (member.kind === "cdb") {
144
+ return {
145
+ input: member.resolution.identity,
146
+ identity: member.resolution.identity,
147
+ recordIndex: member.recordIndex,
148
+ };
149
+ }
150
+ return { input: member.input };
151
+ }
152
+
153
+ /**
154
+ * Ad-hoc literal targets have no CDB record. `resolveRetrieveRequest` may borrow
155
+ * the `__default__` record's index when filling fallback creds, so the inner
156
+ * envelope would carry that borrowed `recordIndex` — making distinct literals
157
+ * collide on one index. Drop it for literal members and stamp the caller's `input`
158
+ * so the target stays identifiable in renderers (mirrors `api-fanout.ts`).
159
+ */
160
+ function relabelAdhocMember(
161
+ envelope: RetrieveEnvelope,
162
+ member: CdbSelectionMember,
163
+ ): RetrieveEnvelope {
164
+ if (member.kind !== "literal") {
165
+ return envelope;
166
+ }
167
+ const { recordIndex: _borrowed, ...target } = envelope.meta.target;
168
+ return {
169
+ ...envelope,
170
+ meta: { ...envelope.meta, target: { ...target, input: member.input } },
171
+ };
172
+ }
173
+
113
174
  /**
114
- * Run a `retrieve` group fanout. Always returns a success envelope when the
175
+ * Run a `retrieve` fan-out. Always returns a success envelope when the
115
176
  * orchestration completes (even if every target failed); throws only for
116
177
  * pre-flight failures (bad request shape, CDB decrypt) the caller renders as an
117
178
  * outer error.
118
179
  */
119
- export async function retrieveGroup(
180
+ export async function retrieveFanout(
120
181
  request: RetrieveRequest,
182
+ selection: TargetSelection,
121
183
  env: Record<string, string | undefined> = Bun.env,
122
- internals: RetrieveGroupInternals = {},
184
+ internals: RetrieveFanoutInternals = {},
185
+ options: RetrieveFanoutOptions = {},
123
186
  ): 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
187
  const attributeSelections = validateRetrieveRequestShape(request);
188
+ const config = await loadEnvFileDefaults(env);
135
189
  const global = resolveRetrieveGlobalContext(
136
190
  request,
137
191
  env,
138
192
  attributeSelections,
193
+ config,
139
194
  );
140
- if (request.concurrency !== undefined) {
141
- resolveFanoutConcurrency(request.concurrency, global.via);
142
- }
195
+ const selectionSummary = summarizeSelection(selection);
143
196
 
144
- const expand = internals.expand ?? expandCdbGroup;
197
+ const expand = internals.expand ?? expandCdbSelection;
145
198
  const expansion = await expand(
199
+ selection,
146
200
  {
147
- group,
148
201
  cdbFile: request.cdbFile,
149
202
  cdbPassword: request.cdbPassword,
203
+ allowAdhoc: options.allowAdhoc ?? true,
150
204
  },
151
205
  env,
206
+ config,
152
207
  );
153
208
 
154
209
  const concurrencyVia = resolveFanoutConcurrencyProtocol(
@@ -158,13 +213,13 @@ export async function retrieveGroup(
158
213
  expansion,
159
214
  );
160
215
  const concurrency = resolveFanoutConcurrency(
161
- request.concurrency,
216
+ options.concurrency ?? request.concurrency,
162
217
  concurrencyVia,
163
218
  );
164
219
 
165
220
  if (expansion.empty) {
166
- return buildFanoutEnvelope({
167
- group,
221
+ return retrieveFanoutEnvelope({
222
+ selection: selectionSummary,
168
223
  concurrency,
169
224
  summary: { total: 0, ok: 0, failed: 0 },
170
225
  requestSummary: global.summary,
@@ -176,38 +231,70 @@ export async function retrieveGroup(
176
231
  }
177
232
 
178
233
  const execute = internals.execute ?? runResolvedRetrieve;
179
- const sleep = internals.sleep ?? defaultSleep;
180
-
181
- const targets = await runBoundedPool(
182
- expansion.targets,
234
+ const sleep = internals.sleep ?? defaultFanoutSleep;
235
+
236
+ const targets = await runFanout<
237
+ CdbSelectionMember,
238
+ ResolvedRetrieveRequest,
239
+ RetrieveEnvelope
240
+ >({
241
+ members: expansion.targets,
183
242
  concurrency,
184
- async (member): Promise<RetrieveEnvelope> => {
185
- let resolved: ResolvedRetrieveRequest;
186
- try {
243
+ resolve: async (member) => {
244
+ if (member.kind === "cdb") {
245
+ if (isDefaultRecordTarget(member.resolution.target)) {
246
+ throw new CentrsError({
247
+ code: "target/unresolved",
248
+ summary:
249
+ "The `__default__` record is a credential fallback, not a connectable router.",
250
+ remediation:
251
+ "Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
252
+ context: { target: member.resolution.target },
253
+ });
254
+ }
187
255
  const macResolution = await resolveMacForRetrieve(
188
256
  request,
189
257
  env,
190
258
  member.resolution,
259
+ config,
191
260
  );
192
- resolved = buildResolvedRetrieve(
261
+ return buildResolvedRetrieve(
193
262
  request,
194
263
  env,
195
264
  member.resolution,
196
265
  attributeSelections,
197
266
  macResolution,
267
+ config,
198
268
  );
199
- } catch (error) {
200
- return buildResolveFailureEnvelope(member.resolution, error);
201
269
  }
202
- return runTargetWithRetry(resolved, member.recordIndex, execute, sleep);
270
+ // Ad-hoc literal: full single-target resolution (CDB load → `__default__`
271
+ // fallback creds), mirroring single-target `retrieve`.
272
+ return resolveRetrieveRequest(
273
+ { ...request, targetInput: member.input },
274
+ env,
275
+ );
203
276
  },
204
- );
277
+ onResolveError: (member, error) =>
278
+ buildFanoutResolveFailure<RetrieveOperationMeta>({
279
+ error,
280
+ target: memberTargetMeta(member),
281
+ warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
282
+ summary: "Failed to resolve a fanout target.",
283
+ }),
284
+ execute: async (resolved, member) =>
285
+ relabelAdhocMember(await execute(resolved), member),
286
+ onExecuteError: (resolved, member, error) =>
287
+ relabelAdhocMember(
288
+ buildRetrieveErrorEnvelopeFromResolved(resolved, error),
289
+ member,
290
+ ),
291
+ sleep,
292
+ });
205
293
 
206
- const summary = summarizeFanout(targets);
207
- return buildFanoutEnvelope({
208
- group,
294
+ return retrieveFanoutEnvelope({
295
+ selection: selectionSummary,
209
296
  concurrency,
210
- summary,
297
+ summary: summarizeFanout(targets),
211
298
  requestSummary: global.summary,
212
299
  settings: global.settings,
213
300
  via: commonVia(targets),
@@ -216,6 +303,37 @@ export async function retrieveGroup(
216
303
  });
217
304
  }
218
305
 
306
+ /**
307
+ * Back-compat shim for the `--group`-only fan-out: builds a single-group
308
+ * {@link TargetSelection} and delegates to {@link retrieveFanout}. Kept for the
309
+ * MCP `centrs_retrieve` tool, whose surface exposes only `group`.
310
+ */
311
+ export async function retrieveGroup(
312
+ request: RetrieveRequest,
313
+ env: Record<string, string | undefined> = Bun.env,
314
+ internals: RetrieveFanoutInternals = {},
315
+ ): Promise<RetrieveFanoutEnvelope> {
316
+ const group = request.group;
317
+ if (group === undefined || group.length === 0) {
318
+ throw new CentrsError({
319
+ code: "usage/missing-group",
320
+ summary: "retrieve group fanout requires a non-empty `--group` value.",
321
+ remediation:
322
+ "Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
323
+ });
324
+ }
325
+ const selection: TargetSelection = {
326
+ positionals: [],
327
+ groups: [group],
328
+ all: false,
329
+ default: false,
330
+ where: [],
331
+ };
332
+ return retrieveFanout(request, selection, env, internals, {
333
+ concurrency: request.concurrency,
334
+ });
335
+ }
336
+
219
337
  /** Outer error envelope for a fanout that failed before per-target results. */
220
338
  export function buildRetrieveFanoutErrorEnvelope(
221
339
  request: RetrieveRequest,
@@ -226,7 +344,7 @@ export function buildRetrieveFanoutErrorEnvelope(
226
344
  ? error
227
345
  : new CentrsError({
228
346
  code: "internal/unhandled",
229
- summary: "retrieve group fanout failed with an unexpected error.",
347
+ summary: "retrieve fan-out failed with an unexpected error.",
230
348
  remediation:
231
349
  "Re-run with `--format json` to capture the structured error details for debugging.",
232
350
  cause: error,
@@ -251,40 +369,24 @@ export function buildRetrieveFanoutErrorEnvelope(
251
369
  };
252
370
  }
253
371
 
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
372
  function resolveFanoutConcurrencyProtocol(
277
373
  request: RetrieveRequest,
278
374
  env: Record<string, string | undefined>,
279
375
  globalVia: RouterOsProtocol,
280
- expansion: CdbGroupExpansion,
376
+ expansion: CdbSelectionExpansion,
281
377
  ): RouterOsProtocol {
378
+ // Deliberately CLI/env only, not `config` — a `centrs.env` default is the
379
+ // weakest precedence tier and must not be treated as a hard pin the way an
380
+ // explicit `--via`/`CENTRS_VIA` is; doing so would defeat per-target CDB
381
+ // `via=` overrides for members that disagree with the global default.
282
382
  if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
283
383
  return globalVia;
284
384
  }
285
385
  if (
286
386
  expansion.targets.some(
287
- (target) => target.resolution.overrides.via?.value === "native-api",
387
+ (member) =>
388
+ member.kind === "cdb" &&
389
+ member.resolution.overrides.via?.value === "native-api",
288
390
  )
289
391
  ) {
290
392
  return "native-api";
@@ -292,124 +394,8 @@ function resolveFanoutConcurrencyProtocol(
292
394
  return globalVia;
293
395
  }
294
396
 
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
397
  interface FanoutEnvelopeInput {
412
- group: string;
398
+ selection: RetrieveSelectionSummary;
413
399
  concurrency: number;
414
400
  summary: FanoutSummary;
415
401
  requestSummary: RetrieveRequestSummary;
@@ -419,54 +405,28 @@ interface FanoutEnvelopeInput {
419
405
  targets: readonly RetrieveEnvelope[];
420
406
  }
421
407
 
422
- function buildFanoutEnvelope(
408
+ /** retrieve's outer fan-out success envelope (delegates to the shared core). */
409
+ function retrieveFanoutEnvelope(
423
410
  input: FanoutEnvelopeInput,
424
411
  ): RetrieveFanoutEnvelope {
425
- return {
426
- ok: true,
427
- data: {
428
- summary: input.summary,
429
- targets: input.targets,
430
- },
412
+ return buildFanoutEnvelope<
413
+ unknown,
414
+ RetrieveOperationMeta,
415
+ RetrieveFanoutOperationMeta
416
+ >({
417
+ summary: input.summary,
418
+ targets: input.targets,
431
419
  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
- },
420
+ settings: input.settings,
421
+ via: input.via,
422
+ operation: {
423
+ kind: "fanout",
424
+ concurrency: input.concurrency,
425
+ summary: input.summary,
426
+ request: input.requestSummary,
427
+ selection: input.selection,
444
428
  },
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));
429
+ });
470
430
  }
471
431
 
472
432
  /**
@@ -504,12 +464,16 @@ function renderFanoutText(
504
464
  const operation = envelope.meta.operation;
505
465
  const { total, ok, failed } = envelope.data.summary;
506
466
  const lines: string[] = [
507
- `group ${operation?.group ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
467
+ `retrieve ${operation?.request.path ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
508
468
  ];
509
469
 
510
470
  for (const target of envelope.data.targets) {
511
471
  const meta = target.meta;
512
- const label = meta.target.identity ?? meta.target.host ?? "(unknown)";
472
+ const label =
473
+ meta.target.identity ??
474
+ meta.target.host ??
475
+ meta.target.input ??
476
+ "(unknown)";
513
477
  const index = meta.target.recordIndex ?? "-";
514
478
  if (target.ok) {
515
479
  lines.push(