@tikoci/centrs 0.1.2 → 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 (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
@@ -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
 
@@ -33,19 +38,24 @@ import type {
33
38
  FanoutSummary,
34
39
  } from "./core/envelope.ts";
35
40
  import {
41
+ buildFanoutEnvelope,
42
+ buildFanoutResolveFailure,
43
+ commonVia,
36
44
  defaultFanoutSleep,
37
45
  resolveFanoutConcurrency,
38
- runBoundedPool,
39
- runWithRetry,
46
+ runFanout,
40
47
  summarizeFanout,
41
48
  } from "./core/fanout.ts";
42
49
  import { CentrsError, serializeCentrsError } from "./errors.ts";
43
50
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
44
51
  import {
45
- type CdbGroupExpansion,
46
- type CdbGroupResolveInput,
47
- type CdbResolution,
48
- expandCdbGroup,
52
+ type CdbSelectionExpansion,
53
+ type CdbSelectionMember,
54
+ type CdbSelectionResolveInput,
55
+ expandCdbSelection,
56
+ isDefaultRecordTarget,
57
+ loadEnvFileDefaults,
58
+ type TargetSelection,
49
59
  } from "./resolver/index.ts";
50
60
  import {
51
61
  buildResolvedRetrieve,
@@ -59,17 +69,27 @@ import {
59
69
  type RetrieveSuccessEnvelope,
60
70
  resolveMacForRetrieve,
61
71
  resolveRetrieveGlobalContext,
72
+ resolveRetrieveRequest,
62
73
  runResolvedRetrieve,
63
74
  toYaml,
64
75
  validateRetrieveRequestShape,
65
76
  } from "./retrieve.ts";
66
77
 
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
+ }
86
+
67
87
  export interface RetrieveFanoutOperationMeta {
68
88
  kind: "fanout";
69
- group: string;
70
89
  concurrency: number;
71
90
  summary: FanoutSummary;
72
91
  request: RetrieveRequestSummary;
92
+ selection: RetrieveSelectionSummary;
73
93
  }
74
94
 
75
95
  export type RetrieveFanoutData = FanoutData<unknown, RetrieveOperationMeta>;
@@ -81,60 +101,109 @@ export type RetrieveFanoutErrorEnvelope =
81
101
  CentrsErrorEnvelope<RetrieveFanoutOperationMeta>;
82
102
 
83
103
  /** Test/override seams; production callers pass nothing. */
84
- export interface RetrieveGroupInternals {
104
+ export interface RetrieveFanoutInternals {
85
105
  /** Per-target executor (attempts; throws on failure). Defaults to the live tail. */
86
106
  execute?: (
87
107
  resolved: ResolvedRetrieveRequest,
88
- recordIndex: number,
89
108
  ) => Promise<RetrieveSuccessEnvelope>;
90
- /** Group expansion. Defaults to the real CDB-backed expansion. */
109
+ /** Selection expansion. Defaults to the real CDB-backed expansion. */
91
110
  expand?: (
92
- input: CdbGroupResolveInput,
111
+ selection: TargetSelection,
112
+ input: CdbSelectionResolveInput,
93
113
  env: Record<string, string | undefined>,
94
- ) => Promise<CdbGroupExpansion>;
114
+ config?: Record<string, string | undefined>,
115
+ ) => Promise<CdbSelectionExpansion>;
95
116
  /** Backoff sleeper. Defaults to a real timer; tests pass a no-op. */
96
117
  sleep?: (ms: number) => Promise<void>;
97
118
  }
98
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
+
99
153
  /**
100
- * Run a `retrieve` group fanout. Always returns a success envelope when the
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
+
174
+ /**
175
+ * Run a `retrieve` fan-out. Always returns a success envelope when the
101
176
  * orchestration completes (even if every target failed); throws only for
102
177
  * pre-flight failures (bad request shape, CDB decrypt) the caller renders as an
103
178
  * outer error.
104
179
  */
105
- export async function retrieveGroup(
180
+ export async function retrieveFanout(
106
181
  request: RetrieveRequest,
182
+ selection: TargetSelection,
107
183
  env: Record<string, string | undefined> = Bun.env,
108
- internals: RetrieveGroupInternals = {},
184
+ internals: RetrieveFanoutInternals = {},
185
+ options: RetrieveFanoutOptions = {},
109
186
  ): Promise<RetrieveFanoutEnvelope> {
110
- const group = request.group;
111
- if (group === undefined || group.length === 0) {
112
- throw new CentrsError({
113
- code: "usage/missing-group",
114
- summary: "retrieve group fanout requires a non-empty `--group` value.",
115
- remediation:
116
- "Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
117
- });
118
- }
119
-
120
187
  const attributeSelections = validateRetrieveRequestShape(request);
188
+ const config = await loadEnvFileDefaults(env);
121
189
  const global = resolveRetrieveGlobalContext(
122
190
  request,
123
191
  env,
124
192
  attributeSelections,
193
+ config,
125
194
  );
126
- if (request.concurrency !== undefined) {
127
- resolveFanoutConcurrency(request.concurrency, global.via);
128
- }
195
+ const selectionSummary = summarizeSelection(selection);
129
196
 
130
- const expand = internals.expand ?? expandCdbGroup;
197
+ const expand = internals.expand ?? expandCdbSelection;
131
198
  const expansion = await expand(
199
+ selection,
132
200
  {
133
- group,
134
201
  cdbFile: request.cdbFile,
135
202
  cdbPassword: request.cdbPassword,
203
+ allowAdhoc: options.allowAdhoc ?? true,
136
204
  },
137
205
  env,
206
+ config,
138
207
  );
139
208
 
140
209
  const concurrencyVia = resolveFanoutConcurrencyProtocol(
@@ -144,13 +213,13 @@ export async function retrieveGroup(
144
213
  expansion,
145
214
  );
146
215
  const concurrency = resolveFanoutConcurrency(
147
- request.concurrency,
216
+ options.concurrency ?? request.concurrency,
148
217
  concurrencyVia,
149
218
  );
150
219
 
151
220
  if (expansion.empty) {
152
- return buildFanoutEnvelope({
153
- group,
221
+ return retrieveFanoutEnvelope({
222
+ selection: selectionSummary,
154
223
  concurrency,
155
224
  summary: { total: 0, ok: 0, failed: 0 },
156
225
  requestSummary: global.summary,
@@ -164,40 +233,68 @@ export async function retrieveGroup(
164
233
  const execute = internals.execute ?? runResolvedRetrieve;
165
234
  const sleep = internals.sleep ?? defaultFanoutSleep;
166
235
 
167
- const targets = await runBoundedPool(
168
- expansion.targets,
236
+ const targets = await runFanout<
237
+ CdbSelectionMember,
238
+ ResolvedRetrieveRequest,
239
+ RetrieveEnvelope
240
+ >({
241
+ members: expansion.targets,
169
242
  concurrency,
170
- async (member): Promise<RetrieveEnvelope> => {
171
- let resolved: ResolvedRetrieveRequest;
172
- 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
+ }
173
255
  const macResolution = await resolveMacForRetrieve(
174
256
  request,
175
257
  env,
176
258
  member.resolution,
259
+ config,
177
260
  );
178
- resolved = buildResolvedRetrieve(
261
+ return buildResolvedRetrieve(
179
262
  request,
180
263
  env,
181
264
  member.resolution,
182
265
  attributeSelections,
183
266
  macResolution,
267
+ config,
184
268
  );
185
- } catch (error) {
186
- return buildResolveFailureEnvelope(member.resolution, error);
187
269
  }
188
- return runWithRetry<RetrieveEnvelope>(
189
- () => execute(resolved, member.recordIndex),
190
- (error) => buildRetrieveErrorEnvelopeFromResolved(resolved, error),
191
- { 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,
192
275
  );
193
276
  },
194
- );
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
+ });
195
293
 
196
- const summary = summarizeFanout(targets);
197
- return buildFanoutEnvelope({
198
- group,
294
+ return retrieveFanoutEnvelope({
295
+ selection: selectionSummary,
199
296
  concurrency,
200
- summary,
297
+ summary: summarizeFanout(targets),
201
298
  requestSummary: global.summary,
202
299
  settings: global.settings,
203
300
  via: commonVia(targets),
@@ -206,6 +303,37 @@ export async function retrieveGroup(
206
303
  });
207
304
  }
208
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
+
209
337
  /** Outer error envelope for a fanout that failed before per-target results. */
210
338
  export function buildRetrieveFanoutErrorEnvelope(
211
339
  request: RetrieveRequest,
@@ -216,7 +344,7 @@ export function buildRetrieveFanoutErrorEnvelope(
216
344
  ? error
217
345
  : new CentrsError({
218
346
  code: "internal/unhandled",
219
- summary: "retrieve group fanout failed with an unexpected error.",
347
+ summary: "retrieve fan-out failed with an unexpected error.",
220
348
  remediation:
221
349
  "Re-run with `--format json` to capture the structured error details for debugging.",
222
350
  cause: error,
@@ -245,14 +373,20 @@ function resolveFanoutConcurrencyProtocol(
245
373
  request: RetrieveRequest,
246
374
  env: Record<string, string | undefined>,
247
375
  globalVia: RouterOsProtocol,
248
- expansion: CdbGroupExpansion,
376
+ expansion: CdbSelectionExpansion,
249
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.
250
382
  if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
251
383
  return globalVia;
252
384
  }
253
385
  if (
254
386
  expansion.targets.some(
255
- (target) => target.resolution.overrides.via?.value === "native-api",
387
+ (member) =>
388
+ member.kind === "cdb" &&
389
+ member.resolution.overrides.via?.value === "native-api",
256
390
  )
257
391
  ) {
258
392
  return "native-api";
@@ -260,37 +394,8 @@ function resolveFanoutConcurrencyProtocol(
260
394
  return globalVia;
261
395
  }
262
396
 
263
- function buildResolveFailureEnvelope(
264
- resolution: CdbResolution,
265
- error: unknown,
266
- ): RetrieveEnvelope {
267
- const centrsError =
268
- error instanceof CentrsError
269
- ? error
270
- : new CentrsError({
271
- code: "internal/unhandled",
272
- summary: "Failed to resolve a fanout target.",
273
- cause: error,
274
- });
275
- return {
276
- ok: false,
277
- error: serializeCentrsError(centrsError),
278
- warnings: [...resolution.warnings],
279
- tips: [],
280
- meta: {
281
- target: {
282
- input: resolution.identity,
283
- identity: resolution.identity,
284
- recordIndex: resolution.recordIndex,
285
- },
286
- via: null,
287
- settings: {},
288
- },
289
- };
290
- }
291
-
292
397
  interface FanoutEnvelopeInput {
293
- group: string;
398
+ selection: RetrieveSelectionSummary;
294
399
  concurrency: number;
295
400
  summary: FanoutSummary;
296
401
  requestSummary: RetrieveRequestSummary;
@@ -300,45 +405,28 @@ interface FanoutEnvelopeInput {
300
405
  targets: readonly RetrieveEnvelope[];
301
406
  }
302
407
 
303
- function buildFanoutEnvelope(
408
+ /** retrieve's outer fan-out success envelope (delegates to the shared core). */
409
+ function retrieveFanoutEnvelope(
304
410
  input: FanoutEnvelopeInput,
305
411
  ): RetrieveFanoutEnvelope {
306
- return {
307
- ok: true,
308
- data: {
309
- summary: input.summary,
310
- targets: input.targets,
311
- },
412
+ return buildFanoutEnvelope<
413
+ unknown,
414
+ RetrieveOperationMeta,
415
+ RetrieveFanoutOperationMeta
416
+ >({
417
+ summary: input.summary,
418
+ targets: input.targets,
312
419
  warnings: input.warnings,
313
- tips: [],
314
- meta: {
315
- target: {},
316
- via: input.via,
317
- settings: input.settings,
318
- operation: {
319
- kind: "fanout",
320
- group: input.group,
321
- concurrency: input.concurrency,
322
- summary: input.summary,
323
- request: input.requestSummary,
324
- },
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,
325
428
  },
326
- };
327
- }
328
-
329
- function commonVia(
330
- targets: readonly RetrieveEnvelope[],
331
- ): RouterOsProtocol | null {
332
- let via: RouterOsProtocol | null | undefined;
333
- for (const target of targets) {
334
- const candidate = target.meta.via;
335
- if (via === undefined) {
336
- via = candidate;
337
- } else if (via !== candidate) {
338
- return null;
339
- }
340
- }
341
- return via ?? null;
429
+ });
342
430
  }
343
431
 
344
432
  /**
@@ -376,12 +464,16 @@ function renderFanoutText(
376
464
  const operation = envelope.meta.operation;
377
465
  const { total, ok, failed } = envelope.data.summary;
378
466
  const lines: string[] = [
379
- `group ${operation?.group ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
467
+ `retrieve ${operation?.request.path ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
380
468
  ];
381
469
 
382
470
  for (const target of envelope.data.targets) {
383
471
  const meta = target.meta;
384
- 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)";
385
477
  const index = meta.target.recordIndex ?? "-";
386
478
  if (target.ok) {
387
479
  lines.push(