@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
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Transport-agnostic fan-out engine.
3
+ *
4
+ * `retrieve` group fan-out (`src/retrieve-fanout.ts`) grew the reusable
5
+ * machinery: a bounded worker pool, transport-aware concurrency defaults, a
6
+ * retry allowlist with jittered backoff, and the ok/failed summary. `api` — and
7
+ * any later fan-out command — needs the identical behavior, so it lives here as
8
+ * one vocabulary. The command-specific orchestration (CDB group expansion,
9
+ * per-target resolve, envelope shaping, text rendering) stays in each command's
10
+ * fan-out module; this core only knows about generic items, concurrency, retry,
11
+ * and counting.
12
+ */
13
+
14
+ import { CentrsError, serializeCentrsError } from "../errors.ts";
15
+ import type { RouterOsProtocol } from "../protocols/index.ts";
16
+ import type {
17
+ CentrsEnvelope,
18
+ CentrsErrorEnvelope,
19
+ CentrsSuccessEnvelope,
20
+ CommonSettingsMeta,
21
+ EnvelopeTargetMeta,
22
+ FanoutData,
23
+ FanoutSummary,
24
+ Warning,
25
+ } from "./envelope.ts";
26
+
27
+ /** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
28
+ export const FANOUT_CONCURRENCY_DEFAULTS: Record<
29
+ "rest-api" | "native-api",
30
+ number
31
+ > = {
32
+ "rest-api": 8,
33
+ "native-api": 4,
34
+ };
35
+
36
+ /** Extra attempts after the first try (jittered backoff between them). */
37
+ export const FANOUT_MAX_RETRIES = 2;
38
+
39
+ /** Base backoff in milliseconds; doubled per attempt with up to +base jitter. */
40
+ export const FANOUT_BACKOFF_BASE_MS = 200;
41
+
42
+ /** RouterOS-style codes that are safe to retry under fan-out. */
43
+ export const FANOUT_RETRYABLE_CODES = [
44
+ "transport/network",
45
+ "transport/connection-closed",
46
+ ] as const;
47
+
48
+ /** Resolve and validate the worker-pool size. */
49
+ export function resolveFanoutConcurrency(
50
+ requested: number | undefined,
51
+ via: RouterOsProtocol,
52
+ ): number {
53
+ if (requested !== undefined) {
54
+ if (!Number.isInteger(requested) || requested < 1) {
55
+ throw new CentrsError({
56
+ code: "usage/invalid-concurrency",
57
+ summary: `--concurrency must be an integer >= 1. Received: ${requested}`,
58
+ remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
59
+ context: { concurrency: requested },
60
+ });
61
+ }
62
+ return requested;
63
+ }
64
+ if (via === "native-api") {
65
+ return FANOUT_CONCURRENCY_DEFAULTS["native-api"];
66
+ }
67
+ return FANOUT_CONCURRENCY_DEFAULTS["rest-api"];
68
+ }
69
+
70
+ /**
71
+ * Classify a per-target failure as retryable. Only the locked allowlist retries:
72
+ * generic transport/network failures and dropped/closed connections (including
73
+ * REST 5xx mapped to `transport/connection-closed`). Router-side (`routeros/*`),
74
+ * validation, auth, cdb, target, input, usage, timeout, TLS, connection-refused,
75
+ * and DNS failures are deterministic and never retried.
76
+ */
77
+ export function isRetryableFanoutError(error: unknown): boolean {
78
+ if (!(error instanceof CentrsError)) {
79
+ return false;
80
+ }
81
+ return (FANOUT_RETRYABLE_CODES as readonly string[]).includes(error.code);
82
+ }
83
+
84
+ /** Count inner `ok`/`failed` across a completed target set. */
85
+ export function summarizeFanout(
86
+ targets: readonly { ok: boolean }[],
87
+ ): FanoutSummary {
88
+ let ok = 0;
89
+ let failed = 0;
90
+ for (const target of targets) {
91
+ if (target.ok) {
92
+ ok += 1;
93
+ } else {
94
+ failed += 1;
95
+ }
96
+ }
97
+ return { total: targets.length, ok, failed };
98
+ }
99
+
100
+ /**
101
+ * Ordered bounded worker pool. Runs `worker` over `items` with at most
102
+ * `concurrency` in flight, returning results in INPUT order regardless of
103
+ * completion order.
104
+ */
105
+ export async function runBoundedPool<I, O>(
106
+ items: readonly I[],
107
+ concurrency: number,
108
+ worker: (item: I, index: number) => Promise<O>,
109
+ ): Promise<O[]> {
110
+ const results = new Array<O>(items.length);
111
+ let next = 0;
112
+ const width = Math.max(1, Math.min(concurrency, items.length));
113
+
114
+ async function drain(): Promise<void> {
115
+ for (;;) {
116
+ const index = next;
117
+ next += 1;
118
+ if (index >= items.length) {
119
+ return;
120
+ }
121
+ results[index] = await worker(items[index] as I, index);
122
+ }
123
+ }
124
+
125
+ await Promise.all(Array.from({ length: width }, () => drain()));
126
+ return results;
127
+ }
128
+
129
+ /**
130
+ * 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).
134
+ */
135
+ export async function runWithRetry<T>(
136
+ run: () => Promise<T>,
137
+ recover: (error: unknown) => T,
138
+ options: {
139
+ sleep?: (ms: number) => Promise<void>;
140
+ maxRetries?: number;
141
+ } = {},
142
+ ): Promise<T> {
143
+ const sleep = options.sleep ?? defaultFanoutSleep;
144
+ const maxRetries = options.maxRetries ?? FANOUT_MAX_RETRIES;
145
+ let attempt = 0;
146
+ for (;;) {
147
+ try {
148
+ return await run();
149
+ } catch (error) {
150
+ if (attempt < maxRetries && isRetryableFanoutError(error)) {
151
+ attempt += 1;
152
+ await sleep(fanoutBackoffMs(attempt));
153
+ continue;
154
+ }
155
+ return recover(error);
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Backoff for attempt N: base·2^(N-1) plus up to +base jitter. */
161
+ export function fanoutBackoffMs(attempt: number): number {
162
+ const base = FANOUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
163
+ return base + Math.floor(Math.random() * FANOUT_BACKOFF_BASE_MS);
164
+ }
165
+
166
+ /** Real timer sleep; tests inject a no-op. */
167
+ export function defaultFanoutSleep(ms: number): Promise<void> {
168
+ return new Promise((resolve) => setTimeout(resolve, ms));
169
+ }
170
+
171
+ /**
172
+ * The per-command fan-out loop, generic over the selection member, the resolved
173
+ * per-target plan, and the inner envelope type. Mirrors the original
174
+ * `retrieveGroup` worker exactly: resolve each member (a resolve failure becomes
175
+ * 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}.
179
+ */
180
+ export async function runFanout<Member, Resolved, Env>(opts: {
181
+ members: readonly Member[];
182
+ concurrency: number;
183
+ resolve: (member: Member) => Promise<Resolved> | Resolved;
184
+ onResolveError: (member: Member, error: unknown) => Env;
185
+ execute: (resolved: Resolved, member: Member) => Promise<Env>;
186
+ onExecuteError: (resolved: Resolved, member: Member, error: unknown) => Env;
187
+ sleep?: (ms: number) => Promise<void>;
188
+ }): Promise<Env[]> {
189
+ return runBoundedPool(opts.members, opts.concurrency, async (member) => {
190
+ let resolved: Resolved;
191
+ try {
192
+ resolved = await opts.resolve(member);
193
+ } catch (error) {
194
+ return opts.onResolveError(member, error);
195
+ }
196
+ return runWithRetry<Env>(
197
+ () => opts.execute(resolved, member),
198
+ (error) => opts.onExecuteError(resolved, member, error),
199
+ { sleep: opts.sleep },
200
+ );
201
+ });
202
+ }
203
+
204
+ /**
205
+ * The uniform fan-out process exit code (granular 0/2/1; see
206
+ * `docs/CONSTITUTION.md`, Target selection). `0` = every target ok (or an empty
207
+ * selection); `2` = partial (some ok, some failed); `1` = orchestration error
208
+ * (outer `ok: false`) OR every target failed. Decoupled from the envelope's
209
+ * outer `ok`, which only reports orchestration success.
210
+ */
211
+ export function fanoutExitCode(envelope: {
212
+ ok: boolean;
213
+ data?: { summary?: FanoutSummary };
214
+ }): 0 | 1 | 2 {
215
+ if (!envelope.ok) {
216
+ return 1;
217
+ }
218
+ const summary = envelope.data?.summary;
219
+ if (!summary || summary.total === 0 || summary.failed === 0) {
220
+ return 0;
221
+ }
222
+ if (summary.ok === 0) {
223
+ return 1;
224
+ }
225
+ return 2;
226
+ }
227
+
228
+ /** Collapse per-target `via` to one value, or `null` when targets disagree. */
229
+ export function commonVia(
230
+ targets: readonly { meta: { via: RouterOsProtocol | null } }[],
231
+ ): RouterOsProtocol | null {
232
+ let via: RouterOsProtocol | null | undefined;
233
+ for (const target of targets) {
234
+ const candidate = target.meta.via;
235
+ if (via === undefined) {
236
+ via = candidate;
237
+ } else if (via !== candidate) {
238
+ return null;
239
+ }
240
+ }
241
+ return via ?? null;
242
+ }
243
+
244
+ /**
245
+ * Assemble the locked fan-out success envelope: `data = { summary, targets }`,
246
+ * outer `ok: true` = orchestration succeeded. The command owns its outer
247
+ * `operation` meta shape and passes it in.
248
+ */
249
+ export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
250
+ summary: FanoutSummary;
251
+ targets: readonly CentrsEnvelope<Data, Op>[];
252
+ warnings: readonly Warning[];
253
+ settings: CommonSettingsMeta;
254
+ via: RouterOsProtocol | null;
255
+ operation: OuterOp;
256
+ }): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
257
+ return {
258
+ ok: true,
259
+ data: { summary: input.summary, targets: input.targets },
260
+ warnings: input.warnings,
261
+ tips: [],
262
+ meta: {
263
+ target: {},
264
+ via: input.via,
265
+ settings: input.settings,
266
+ operation: input.operation,
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Build an INNER per-target error envelope for a member that failed to resolve
273
+ * (before any transport attempt). Carries the member's target meta so the
274
+ * fan-out output identifies which device failed.
275
+ */
276
+ export function buildFanoutResolveFailure<Op = unknown>(input: {
277
+ error: unknown;
278
+ target: EnvelopeTargetMeta;
279
+ warnings?: readonly Warning[];
280
+ summary?: string;
281
+ }): CentrsErrorEnvelope<Op> {
282
+ const centrsError =
283
+ input.error instanceof CentrsError
284
+ ? input.error
285
+ : new CentrsError({
286
+ code: "internal/unhandled",
287
+ summary: input.summary ?? "Failed to resolve a fanout target.",
288
+ cause: input.error,
289
+ });
290
+ return {
291
+ ok: false,
292
+ error: serializeCentrsError(centrsError),
293
+ warnings: input.warnings ?? [],
294
+ tips: [],
295
+ meta: {
296
+ target: input.target,
297
+ via: null,
298
+ settings: {},
299
+ },
300
+ };
301
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared `/console/inspect` client primitives.
3
+ *
4
+ * `retrieve` and `execute` each grew their own private copy of these helpers
5
+ * (path tokenizing, the comma-path join, node-type predicates, completion-name
6
+ * extraction). This module is the single home so a third consumer — `api`, and a
7
+ * future `explain`/`check` — reuses one grounded implementation instead of
8
+ * forking a fourth. It deliberately holds only the transport-agnostic
9
+ * *primitives*; each command composes its own discovery strategy on top (their
10
+ * strategies differ: `retrieve` probes print/get support, `execute` unions
11
+ * child-args with completions), so the strategy stays in the command.
12
+ *
13
+ * Grounding: the request modes mirror `tikoci/lsp-routeros-ts`
14
+ * (`server/src/routeros.ts` `InspectRequest`). The behavioral facts (the array-
15
+ * typed `path`, completion shapes) are confirmed on CHR 7.23.1 in
16
+ * `commands/api/AGENTS.md` — read that before changing wire behavior here.
17
+ */
18
+
19
+ import { CentrsError } from "../errors.ts";
20
+
21
+ /**
22
+ * The transport seam this module needs. A {@link ProtocolAdapter} satisfies it
23
+ * structurally; depending on the narrow shape keeps `core/` decoupled from the
24
+ * protocol layer (no import cycle).
25
+ */
26
+ export interface InspectBackend {
27
+ inspect(request: "child" | "completion", path: string): Promise<unknown[]>;
28
+ }
29
+
30
+ /**
31
+ * The `/console/inspect` request modes (lsp's `InspectRequest`). Only `child`
32
+ * and `completion` are wired today (the two the {@link InspectBackend} seam
33
+ * exposes); `highlight` (token-level error detection) and `syntax` (parse tree),
34
+ * plus the `input`/`.query`/`.proplist` request fields, are the documented
35
+ * Phase-2 extension point for value-level validation — widen the adapter seam
36
+ * when adding them.
37
+ */
38
+ export type InspectRequestKind =
39
+ | "child"
40
+ | "completion"
41
+ | "highlight"
42
+ | "syntax";
43
+
44
+ /** A row from `request=child`: a menu/argument node under a path. */
45
+ export interface InspectChildItem {
46
+ type?: string;
47
+ name?: string;
48
+ "node-type"?: string;
49
+ }
50
+
51
+ /** A row from `request=completion`: a candidate token for a path/argument. */
52
+ export interface InspectCompletionItem {
53
+ type?: string;
54
+ name?: string;
55
+ completion?: string;
56
+ value?: string;
57
+ text?: string;
58
+ }
59
+
60
+ /**
61
+ * Split a slash RouterOS path into menu tokens, dropping the leading slash and
62
+ * empties: `"/ip/address"` → `["ip", "address"]`.
63
+ */
64
+ export function pathTokens(path: string): string[] {
65
+ return path.split("/").filter(Boolean);
66
+ }
67
+
68
+ /**
69
+ * Join menu tokens into the COMMA form `/console/inspect` expects for its `path`
70
+ * argument: `["ip", "address"]` → `"ip,address"`.
71
+ *
72
+ * The inspect `path` argument is internally a RouterOS *array*: a comma string
73
+ * is `:toarray`-split into the menu-walk tokens, whereas a `/`-prefixed command
74
+ * string is not split and matches no menu (confirmed on CHR 7.23.1 — slash forms
75
+ * return nothing; see `commands/api/AGENTS.md`). So callers must pass tokens
76
+ * joined by comma, never a slash command.
77
+ */
78
+ export function inspectPath(tokens: readonly string[]): string {
79
+ return tokens.join(",");
80
+ }
81
+
82
+ /** True for an argument (`arg`) node from `request=child`. */
83
+ export function isArgumentNode(child: InspectChildItem): boolean {
84
+ return child.type === "arg" || child["node-type"] === "arg";
85
+ }
86
+
87
+ /** True for a command (`cmd`) node with the given name (e.g. `print`, `get`). */
88
+ export function isCommandNode(child: InspectChildItem, name: string): boolean {
89
+ return (
90
+ child.name === name &&
91
+ (child.type === "cmd" || child["node-type"] === "cmd")
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Flatten completion rows into attribute names. Reads every name-like field a
97
+ * RouterOS build might populate (`completion`/`name`/`value`/`text`), strips
98
+ * everything from the first `=` onward (the `name=value` completion form), and
99
+ * drops blanks. Returns the names in row order **without** de-duplication or
100
+ * sorting — callers that need a stable set wrap with `[...new Set(names)].sort()`
101
+ * (both current callers do).
102
+ */
103
+ export function extractCompletionNames(
104
+ rows: readonly InspectCompletionItem[],
105
+ ): string[] {
106
+ return rows
107
+ .flatMap((row) => [row.completion, row.name, row.value, row.text])
108
+ .filter((value): value is string => typeof value === "string")
109
+ .map((value) => nameBeforeEquals(value).trim())
110
+ .filter((value) => value.length > 0);
111
+ }
112
+
113
+ /**
114
+ * The token before the first `=` in a RouterOS completion row. Plain string
115
+ * slicing (no regex) — `value.replace(/=.*$/, "")` triggers a CodeQL
116
+ * polynomial-ReDoS flag on this exported function's caller-supplied input.
117
+ */
118
+ function nameBeforeEquals(value: string): string {
119
+ const eq = value.indexOf("=");
120
+ return eq === -1 ? value : value.slice(0, eq);
121
+ }
122
+
123
+ /** `request=child` for a token path. */
124
+ export async function inspectChildren(
125
+ backend: InspectBackend,
126
+ tokens: readonly string[],
127
+ ): Promise<InspectChildItem[]> {
128
+ return (await backend.inspect(
129
+ "child",
130
+ inspectPath(tokens),
131
+ )) as InspectChildItem[];
132
+ }
133
+
134
+ /** `request=completion` for a token path. */
135
+ export async function inspectCompletions(
136
+ backend: InspectBackend,
137
+ tokens: readonly string[],
138
+ ): Promise<InspectCompletionItem[]> {
139
+ return (await backend.inspect(
140
+ "completion",
141
+ inspectPath(tokens),
142
+ )) as InspectCompletionItem[];
143
+ }
144
+
145
+ /**
146
+ * {@link inspectChildren} that swallows a "path not found" trap to an empty
147
+ * list, so the caller can classify the absence itself (REST returns an empty
148
+ * child list; native traps). Only the two grounded not-found codes are
149
+ * swallowed — `routeros/unknown-path` (the classified form) and the legacy
150
+ * `routeros/api-trap` catch-all; every other error propagates. Use this for the
151
+ * existence probe, not for attribute/value discovery (where a trap is a real
152
+ * failure).
153
+ */
154
+ export async function inspectChildrenOrEmpty(
155
+ backend: InspectBackend,
156
+ tokens: readonly string[],
157
+ ): Promise<InspectChildItem[]> {
158
+ try {
159
+ return await inspectChildren(backend, tokens);
160
+ } catch (error) {
161
+ if (
162
+ error instanceof CentrsError &&
163
+ (error.code === "routeros/api-trap" ||
164
+ error.code === "routeros/unknown-path")
165
+ ) {
166
+ return [];
167
+ }
168
+ throw error;
169
+ }
170
+ }