@tikoci/centrs 0.1.8 → 0.1.9-next.22

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 (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
@@ -20,7 +20,10 @@ import type {
20
20
  CommonSettingsMeta,
21
21
  EnvelopeTargetMeta,
22
22
  FanoutData,
23
+ FanoutMemberOperation,
23
24
  FanoutSummary,
25
+ SettingSource,
26
+ ValidationTrace,
24
27
  Warning,
25
28
  } from "./envelope.ts";
26
29
 
@@ -81,6 +84,59 @@ export function isRetryableFanoutError(error: unknown): boolean {
81
84
  return (FANOUT_RETRYABLE_CODES as readonly string[]).includes(error.code);
82
85
  }
83
86
 
87
+ /**
88
+ * Transport failures that prove the request never reached RouterOS (or reached
89
+ * it and was refused before running): the local client, connection, name,
90
+ * route, trust or login failed. Any other transport failure after a send is
91
+ * ambiguous.
92
+ */
93
+ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
94
+ "transport/auth-failed",
95
+ "transport/connection-refused",
96
+ "transport/dns",
97
+ "transport/host-key-mismatch",
98
+ "transport/insecure-trust",
99
+ "transport/local-tool-missing",
100
+ "transport/provider-trust",
101
+ "transport/tls-certificate",
102
+ "transport/unreachable",
103
+ ];
104
+
105
+ /**
106
+ * #448: a write whose request was already sent when a transport failure ended
107
+ * it may have run on the router. It is never retried, and its error envelope
108
+ * says the outcome is unknown rather than implying nothing happened.
109
+ */
110
+ export function sentWriteWarnings(
111
+ write: boolean,
112
+ error: unknown,
113
+ trace: ValidationTrace | undefined,
114
+ ): Warning[] {
115
+ if (!write || !trace?.dispatched) return [];
116
+ if (
117
+ !(error instanceof CentrsError) ||
118
+ !error.code.startsWith("transport/") ||
119
+ NOT_SENT_TRANSPORT_CODES.includes(error.code)
120
+ ) {
121
+ return [];
122
+ }
123
+ return [
124
+ {
125
+ code: "transport/write-outcome-unknown",
126
+ message: `The write was sent before it failed with ${error.code}, so it may have run on the router. centrs does not retry a write it has sent; check the router's state before running it again.`,
127
+ context: { cause: error.code },
128
+ },
129
+ ];
130
+ }
131
+
132
+ /** A fan-out retry veto: never replay a write whose request was sent (#448). */
133
+ export function mayRetryUnsentWrite(
134
+ write: boolean,
135
+ trace: ValidationTrace | undefined,
136
+ ): boolean {
137
+ return !write || trace?.dispatched !== true;
138
+ }
139
+
84
140
  /** Count inner `ok`/`failed` across a completed target set. */
85
141
  export function summarizeFanout(
86
142
  targets: readonly { ok: boolean }[],
@@ -128,9 +184,11 @@ export async function runBoundedPool<I, O>(
128
184
 
129
185
  /**
130
186
  * Run `run`, retrying only {@link isRetryableFanoutError} failures up to
131
- * `maxRetries` times with jittered backoff. A non-retryable error, or the final
132
- * exhausted attempt, is converted to a value via `recover` (so a fan-out target
133
- * yields an inner error envelope instead of throwing out of the batch).
187
+ * `maxRetries` times with jittered backoff. `mayRetry` vetoes a retry the
188
+ * allowlist would allow, e.g. a write that was already sent (#448). A
189
+ * non-retryable error, or the final exhausted attempt, is converted to a value
190
+ * via `recover` (so a fan-out target yields an inner error envelope instead of
191
+ * throwing out of the batch).
134
192
  */
135
193
  export async function runWithRetry<T>(
136
194
  run: () => Promise<T>,
@@ -138,6 +196,7 @@ export async function runWithRetry<T>(
138
196
  options: {
139
197
  sleep?: (ms: number) => Promise<void>;
140
198
  maxRetries?: number;
199
+ mayRetry?: (error: unknown) => boolean;
141
200
  } = {},
142
201
  ): Promise<T> {
143
202
  const sleep = options.sleep ?? defaultFanoutSleep;
@@ -147,7 +206,11 @@ export async function runWithRetry<T>(
147
206
  try {
148
207
  return await run();
149
208
  } catch (error) {
150
- if (attempt < maxRetries && isRetryableFanoutError(error)) {
209
+ if (
210
+ attempt < maxRetries &&
211
+ isRetryableFanoutError(error) &&
212
+ (options.mayRetry?.(error) ?? true)
213
+ ) {
151
214
  attempt += 1;
152
215
  await sleep(fanoutBackoffMs(attempt));
153
216
  continue;
@@ -173,9 +236,10 @@ export function defaultFanoutSleep(ms: number): Promise<void> {
173
236
  * per-target plan, and the inner envelope type. Mirrors the original
174
237
  * `retrieveGroup` worker exactly: resolve each member (a resolve failure becomes
175
238
  * an inner error envelope, never a throw out of the batch); run the resolved
176
- * request through {@link runWithRetry} (a retryable transient drop is retried,
177
- * everything else recovers to an inner error envelope). Results are returned in
178
- * input (record-index) order via {@link runBoundedPool}.
239
+ * request through {@link runWithRetry} (a retryable transient drop is retried
240
+ * unless `mayRetry` vetoes it, everything else recovers to an inner error
241
+ * envelope). Results are returned in input (record-index) order via
242
+ * {@link runBoundedPool}.
179
243
  */
180
244
  export async function runFanout<Member, Resolved, Env>(opts: {
181
245
  members: readonly Member[];
@@ -184,6 +248,8 @@ export async function runFanout<Member, Resolved, Env>(opts: {
184
248
  onResolveError: (member: Member, error: unknown) => Env;
185
249
  execute: (resolved: Resolved, member: Member) => Promise<Env>;
186
250
  onExecuteError: (resolved: Resolved, member: Member, error: unknown) => Env;
251
+ /** Veto a retry for this target, e.g. a write that was already sent. */
252
+ mayRetry?: (resolved: Resolved, error: unknown) => boolean;
187
253
  sleep?: (ms: number) => Promise<void>;
188
254
  }): Promise<Env[]> {
189
255
  return runBoundedPool(opts.members, opts.concurrency, async (member) => {
@@ -196,7 +262,12 @@ export async function runFanout<Member, Resolved, Env>(opts: {
196
262
  return runWithRetry<Env>(
197
263
  () => opts.execute(resolved, member),
198
264
  (error) => opts.onExecuteError(resolved, member, error),
199
- { sleep: opts.sleep },
265
+ {
266
+ sleep: opts.sleep,
267
+ mayRetry: opts.mayRetry
268
+ ? (error) => opts.mayRetry?.(resolved, error) ?? true
269
+ : undefined,
270
+ },
200
271
  );
201
272
  });
202
273
  }
@@ -241,29 +312,151 @@ export function commonVia(
241
312
  return via ?? null;
242
313
  }
243
314
 
315
+ /** The part of an outer fan-out `operation` its targets may share. */
316
+ interface SharedOperation {
317
+ request?: unknown;
318
+ auth?: unknown;
319
+ }
320
+
321
+ /**
322
+ * Write shared meta once (#461): the outer envelope keeps the run's settings,
323
+ * request and auth, and each target keeps only what differs from them. A
324
+ * settings key the outer envelope lacks, and the `auth`, move out when every
325
+ * target that reached an operation agrees on them. A target's `settings`
326
+ * keeps the keys whose source differs from the outer key's; its
327
+ * `request`/`auth` are dropped when equal to the outer ones.
328
+ */
329
+ export function compactFanoutTargets<Data, Op, OuterOp extends object>(
330
+ settings: CommonSettingsMeta,
331
+ operation: OuterOp,
332
+ targets: readonly CentrsEnvelope<Data, Op>[],
333
+ ): {
334
+ settings: CommonSettingsMeta;
335
+ operation: OuterOp;
336
+ targets: CentrsEnvelope<Data, FanoutMemberOperation<Op>>[];
337
+ } {
338
+ // A target that failed before it had an operation (a resolve failure)
339
+ // has no settings or auth to agree or disagree with.
340
+ const reached = targets.flatMap((target) => {
341
+ const inner = target.meta.operation as SharedOperation | undefined;
342
+ return inner ? [{ settings: target.meta.settings, auth: inner.auth }] : [];
343
+ });
344
+ const agreed = (value: (member: (typeof reached)[number]) => unknown) => {
345
+ const first = reached[0] && value(reached[0]);
346
+ return first !== undefined &&
347
+ reached.every((member) => Bun.deepEquals(value(member), first))
348
+ ? first
349
+ : undefined;
350
+ };
351
+ const outerSettings: CommonSettingsMeta = { ...settings };
352
+ for (const key of Object.keys(reached[0]?.settings ?? {})) {
353
+ if (outerSettings[key] !== undefined) continue;
354
+ const source = agreed((member) => member.settings[key]);
355
+ if (source !== undefined) outerSettings[key] = source as SettingSource;
356
+ }
357
+ const outer = operation as SharedOperation;
358
+ const auth = outer.auth === undefined ? agreed((m) => m.auth) : undefined;
359
+ const shared: SharedOperation =
360
+ auth === undefined ? outer : { ...outer, auth };
361
+ return {
362
+ settings: outerSettings,
363
+ operation: shared as OuterOp,
364
+ targets: targets.map((target) => {
365
+ const own: CommonSettingsMeta = {};
366
+ // An `undefined` source is never written: JSON drops it, so a direct
367
+ // and a serialized reader would disagree on what the target inherits.
368
+ for (const [key, source] of Object.entries(target.meta.settings)) {
369
+ if (source !== undefined && !Bun.deepEquals(source, outerSettings[key]))
370
+ own[key] = source;
371
+ }
372
+ const meta = { ...target.meta, settings: own };
373
+ const inner = target.meta.operation as
374
+ | (Record<string, unknown> & SharedOperation)
375
+ | undefined;
376
+ if (inner) {
377
+ const { request, auth, ...rest } = inner;
378
+ // `auth` is shared only when every reached target has the same
379
+ // defined one, and every inner operation type requires `request`,
380
+ // so an omitted field always means "equal to the outer one".
381
+ const differs = (value: unknown, outer: unknown) =>
382
+ value !== undefined && !Bun.deepEquals(value, outer);
383
+ meta.operation = {
384
+ ...rest,
385
+ ...(differs(request, shared.request) ? { request } : {}),
386
+ ...(differs(auth, shared.auth) ? { auth } : {}),
387
+ } as Op;
388
+ }
389
+ return { ...target, meta } as CentrsEnvelope<
390
+ Data,
391
+ FanoutMemberOperation<Op>
392
+ >;
393
+ }),
394
+ };
395
+ }
396
+
397
+ /**
398
+ * The inverse of {@link compactFanoutTargets}: a target's full meta, read as
399
+ * `target ?? outer` (per key for settings). A target that failed before it
400
+ * had an operation gets no operation.
401
+ */
402
+ export function expandFanoutTarget<Data, Op>(
403
+ outer: { meta: { settings: CommonSettingsMeta; operation?: unknown } },
404
+ target: CentrsEnvelope<Data, FanoutMemberOperation<Op>>,
405
+ ): CentrsEnvelope<Data, Op> {
406
+ const shared = (outer.meta.operation ?? {}) as SharedOperation;
407
+ const inner = target.meta.operation as
408
+ | (Record<string, unknown> & SharedOperation)
409
+ | undefined;
410
+ const settings: CommonSettingsMeta = { ...outer.meta.settings };
411
+ for (const [key, source] of Object.entries(target.meta.settings)) {
412
+ if (source !== undefined) settings[key] = source;
413
+ }
414
+ const operation = inner && {
415
+ ...inner,
416
+ ...(inner.request === undefined && shared.request !== undefined
417
+ ? { request: shared.request }
418
+ : {}),
419
+ ...(inner.auth === undefined && shared.auth !== undefined
420
+ ? { auth: shared.auth }
421
+ : {}),
422
+ };
423
+ return {
424
+ ...target,
425
+ meta: { ...target.meta, settings, ...(operation ? { operation } : {}) },
426
+ } as CentrsEnvelope<Data, Op>;
427
+ }
428
+
244
429
  /**
245
430
  * Assemble the locked fan-out success envelope: `data = { summary, targets }`,
246
431
  * outer `ok: true` = orchestration succeeded. The command owns its outer
247
- * `operation` meta shape and passes it in.
432
+ * `operation` meta shape and passes it in; shared meta is hoisted out of the
433
+ * targets by {@link compactFanoutTargets}. `target` is the outer target, set
434
+ * only when the fan-out runs through one router (a CMR controller).
248
435
  */
249
- export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
436
+ export function buildFanoutEnvelope<Data, Op, OuterOp extends object>(input: {
250
437
  summary: FanoutSummary;
251
438
  targets: readonly CentrsEnvelope<Data, Op>[];
252
439
  warnings: readonly Warning[];
253
440
  settings: CommonSettingsMeta;
254
441
  via: RouterOsProtocol | null;
255
442
  operation: OuterOp;
443
+ target?: EnvelopeTargetMeta;
256
444
  }): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
445
+ const { settings, operation, targets } = compactFanoutTargets(
446
+ input.settings,
447
+ input.operation,
448
+ input.targets,
449
+ );
257
450
  return {
258
451
  ok: true,
259
- data: { summary: input.summary, targets: input.targets },
452
+ data: { summary: input.summary, targets },
260
453
  warnings: input.warnings,
261
454
  tips: [],
262
455
  meta: {
263
- target: {},
456
+ target: input.target ?? {},
264
457
  via: input.via,
265
- settings: input.settings,
266
- operation: input.operation,
458
+ settings,
459
+ operation,
267
460
  },
268
461
  };
269
462
  }
package/src/errors.ts CHANGED
@@ -17,6 +17,7 @@ export type CentrsErrorCode =
17
17
  | `wait/${string}`
18
18
  | `auth/${string}`
19
19
  | `cdb/${string}`
20
+ | `cmr/${string}`
20
21
  | `discover/${string}`
21
22
  | `identity/${string}`
22
23
  | `input/${string}`
@@ -264,11 +265,12 @@ export function serializeUnknownError(error: unknown): unknown {
264
265
  return serializeCentrsError(error);
265
266
  }
266
267
 
268
+ // No `stack`: it is raw exception text with local install paths, and the
269
+ // constitution summarizes those rather than embedding them (#476).
267
270
  if (error instanceof Error) {
268
271
  return {
269
272
  name: error.name,
270
273
  message: error.message,
271
- stack: error.stack,
272
274
  code: extractErrorCode(error),
273
275
  };
274
276
  }