@tikoci/centrs 0.1.7 → 0.1.8-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/retrieve/README.md +348 -4
  14. package/commands/retrieve/examples.md +422 -5
  15. package/commands/terminal/README.md +14 -4
  16. package/commands/terminal/examples.md +22 -3
  17. package/commands/transfer/README.md +55 -9
  18. package/commands/transfer/examples.md +106 -7
  19. package/docs/CLI.md +28 -20
  20. package/docs/CONSTITUTION.md +30 -10
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -1
  23. package/docs/errors/input/invalid-query.md +36 -0
  24. package/docs/errors/input/local-destination.md +20 -0
  25. package/docs/errors/input/unsupported-query.md +19 -0
  26. package/docs/errors/internal/byte-string.md +13 -0
  27. package/docs/errors/settings/invalid-timeout.md +6 -0
  28. package/docs/errors/transport/stream-overflow.md +21 -0
  29. package/docs/errors/usage/removed-flag.md +20 -0
  30. package/docs/errors/validation/menu-unavailable.md +22 -0
  31. package/docs/errors/validation/not-followable.md +23 -0
  32. package/docs/errors/validation/package-missing.md +29 -0
  33. package/docs/errors/validation/unknown-path.md +16 -4
  34. package/docs/errors/wait/deadline-exceeded.md +14 -0
  35. package/docs/errors/wait/interrupted.md +10 -0
  36. package/package.json +14 -11
  37. package/src/absent-menu.ts +280 -0
  38. package/src/api-fanout.ts +22 -12
  39. package/src/api.ts +511 -186
  40. package/src/btest.ts +19 -17
  41. package/src/cli/api.ts +126 -34
  42. package/src/cli/execute.ts +18 -5
  43. package/src/cli/retrieve.ts +216 -22
  44. package/src/cli/terminal.ts +10 -5
  45. package/src/cli/transfer.ts +23 -8
  46. package/src/core/envelope.ts +14 -0
  47. package/src/core/error-catalog.ts +56 -1
  48. package/src/core/inspect.ts +43 -5
  49. package/src/core/query.ts +508 -0
  50. package/src/core/routeros-errors.ts +1 -1
  51. package/src/devices.ts +6 -9
  52. package/src/discover.ts +7 -8
  53. package/src/errors.ts +116 -7
  54. package/src/execute-fanout.ts +13 -8
  55. package/src/execute.ts +106 -65
  56. package/src/explain/catalog.ts +58 -10
  57. package/src/explain/menus.ts +23 -2
  58. package/src/explain.ts +6 -6
  59. package/src/index.ts +29 -1
  60. package/src/mcp/tools.ts +9 -14
  61. package/src/protocols/adapter.ts +224 -118
  62. package/src/protocols/btest-session.ts +3 -2
  63. package/src/protocols/endpoint.ts +10 -0
  64. package/src/protocols/mac-telnet-console.ts +6 -3
  65. package/src/protocols/native-api.ts +223 -70
  66. package/src/protocols/sftp.ts +44 -14
  67. package/src/protocols/ssh.ts +42 -14
  68. package/src/resolver/cdb.ts +28 -7
  69. package/src/resolver/index.ts +1 -0
  70. package/src/resolver/target.ts +53 -7
  71. package/src/retrieve-fanout.ts +51 -12
  72. package/src/retrieve-follow.ts +862 -0
  73. package/src/retrieve-sample.ts +244 -0
  74. package/src/retrieve-stream.ts +115 -0
  75. package/src/retrieve-wait.ts +286 -0
  76. package/src/retrieve.ts +727 -108
  77. package/src/settings.ts +15 -15
  78. package/src/transfer-fanout.ts +40 -15
  79. package/src/transfer.ts +234 -32
@@ -0,0 +1,862 @@
1
+ /**
2
+ * `retrieve --follow`: keep a menu's rows current over native-api `listen`.
3
+ * The contract (line kinds, A1 bootstrap, `.id` sweep, bounds) is
4
+ * `commands/retrieve/README.md` → Follow; the CHR evidence is #396.
5
+ *
6
+ * One native connection carries the `listen` (started first), snapshot,
7
+ * membership reads and periodic sweep. Replies land in one arrival-ordered
8
+ * queue. Filtered membership adds per-id wire versions so delayed replies
9
+ * cannot overwrite newer changes; membership reads and sweeps are serialized.
10
+ */
11
+
12
+ import {
13
+ buildTip,
14
+ type EnvelopeValidationMeta,
15
+ type Tip,
16
+ type Warning,
17
+ } from "./core/envelope.ts";
18
+ import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
19
+ import { CentrsError } from "./errors.ts";
20
+ import {
21
+ createProtocolAdapter,
22
+ type ProtocolAdapter,
23
+ type ProtocolApiRequest,
24
+ type ProtocolListenOptions,
25
+ type ProtocolTappedReply,
26
+ } from "./protocols/adapter.ts";
27
+ import {
28
+ buildRetrieveErrorEnvelope,
29
+ buildRetrieveErrorEnvelopeFromResolved,
30
+ type ResolvedRetrieveRequest,
31
+ type RetrieveEnvelope,
32
+ type RetrieveFollowCounts,
33
+ type RetrieveFollowStopReason,
34
+ type RetrieveFollowSummary,
35
+ type RetrieveRequest,
36
+ type RetrieveStreamMeta,
37
+ type RetrieveSuccessEnvelope,
38
+ resolveRetrieveRequest,
39
+ validateRetrieveRead,
40
+ } from "./retrieve.ts";
41
+ import { failedStreamEnvelope, streamEnvelope } from "./retrieve-stream.ts";
42
+
43
+ /** Changes allowed to wait unread before the follow ends (`transport/stream-overflow`). */
44
+ export const FOLLOW_BUFFER_LIMIT = 50_000;
45
+
46
+ export interface RetrieveFollowOptions {
47
+ /** Ctrl-C: ends the follow with `stopReason: "interrupted"`. */
48
+ signal?: AbortSignal;
49
+ /** Fired once the `listen` is on the wire, before the snapshot `print`. */
50
+ onListening?: () => void;
51
+ /** Override {@link FOLLOW_BUFFER_LIMIT} (tests). */
52
+ bufferLimit?: number;
53
+ }
54
+
55
+ type FollowEvent =
56
+ | { type: "listen"; reply: ProtocolTappedReply; version?: number }
57
+ | { type: "listen-end" }
58
+ | { type: "snapshot-row"; row: Record<string, string> }
59
+ | { type: "snapshot-done"; bootstrap?: Map<string, number> }
60
+ | { type: "sweep-start" }
61
+ | { type: "sweep"; ids: Set<string> }
62
+ | {
63
+ type: "membership";
64
+ versions: Map<string, number>;
65
+ rows: Map<string, Record<string, string>>;
66
+ }
67
+ | { type: "stop" }
68
+ | { type: "error"; error: unknown };
69
+
70
+ /**
71
+ * The wire-ordered event queue: every source feeds it from a synchronous
72
+ * reply tap (`ProtocolListenOptions.onReply`), so its order is the order the
73
+ * replies arrived on the connection. A stop request and a fatal error jump the
74
+ * queue: Ctrl-C, an elapsed `--duration` or an overflow must not wait behind
75
+ * a backlog. Head-indexed, because `shift()` is O(n) per event.
76
+ */
77
+ class FollowQueue {
78
+ private events: FollowEvent[] = [];
79
+ private head = 0;
80
+ private wake: (() => void) | undefined;
81
+ private stopRequested = false;
82
+ private fatal: { error: unknown } | undefined;
83
+
84
+ get length(): number {
85
+ return this.events.length - this.head;
86
+ }
87
+
88
+ /** The first fatal error recorded, including one that arrived after the loop left. */
89
+ get fatalError(): { error: unknown } | undefined {
90
+ return this.fatal;
91
+ }
92
+
93
+ /**
94
+ * The stop or fatal error waiting to jump the queue, if any. A loop that
95
+ * yields a batch of frames between `next()` calls checks this per frame.
96
+ */
97
+ halted(): FollowEvent | undefined {
98
+ if (this.fatal) return { type: "error", error: this.fatal.error };
99
+ if (this.stopRequested) return { type: "stop" };
100
+ return undefined;
101
+ }
102
+
103
+ push(event: FollowEvent): void {
104
+ this.events.push(event);
105
+ this.signal();
106
+ }
107
+
108
+ requestStop(): void {
109
+ this.stopRequested = true;
110
+ this.signal();
111
+ }
112
+
113
+ fail(error: unknown): void {
114
+ this.fatal ??= { error };
115
+ this.signal();
116
+ }
117
+
118
+ async next(): Promise<FollowEvent> {
119
+ for (;;) {
120
+ const halted = this.halted();
121
+ if (halted) return halted;
122
+ if (this.head < this.events.length) {
123
+ const event = this.events[this.head] as FollowEvent;
124
+ this.head += 1;
125
+ if (this.head > 1024 && this.head * 2 > this.events.length) {
126
+ this.events.splice(0, this.head);
127
+ this.head = 0;
128
+ }
129
+ return event;
130
+ }
131
+ await new Promise<void>((resolve) => {
132
+ this.wake = resolve;
133
+ });
134
+ }
135
+ }
136
+
137
+ private signal(): void {
138
+ const resume = this.wake;
139
+ this.wake = undefined;
140
+ resume?.();
141
+ }
142
+ }
143
+
144
+ type FrameMeta = Extract<RetrieveStreamMeta, { kind: "frame" }>;
145
+
146
+ /**
147
+ * Follow one menu: yields an optional `notice`, the snapshot frames, `synced`,
148
+ * live frames, then exactly one summary (successful, or an error envelope
149
+ * carrying the partial counts). Errors before the follow starts (resolution,
150
+ * validation, a non-followable menu, a non-native transport) yield one error
151
+ * envelope without a summary.
152
+ */
153
+ export async function* retrieveFollow(
154
+ request: RetrieveRequest,
155
+ env: Record<string, string | undefined> = Bun.env,
156
+ options: RetrieveFollowOptions = {},
157
+ ): AsyncGenerator<RetrieveEnvelope, void, void> {
158
+ const followRequest: RetrieveRequest = { ...request, follow: true };
159
+ let resolved: ResolvedRetrieveRequest;
160
+ try {
161
+ resolved = await resolveRetrieveRequest(followRequest, env);
162
+ } catch (error) {
163
+ yield buildRetrieveErrorEnvelope(followRequest, error);
164
+ return;
165
+ }
166
+ const startedAt = Date.now();
167
+ // A caller that cancelled before the follow began gets its summary
168
+ // without any device work.
169
+ if (options.signal?.aborted) {
170
+ yield interruptedSummary(
171
+ resolved,
172
+ { enabled: resolved.validate.value, result: "skipped" },
173
+ startedAt,
174
+ );
175
+ return;
176
+ }
177
+ if (resolved.via.value !== "native-api") {
178
+ yield buildRetrieveErrorEnvelopeFromResolved(
179
+ resolved,
180
+ new CentrsError({
181
+ code: "transport/capability-unsupported",
182
+ summary: `\`retrieve --follow\` needs native-api; ${resolved.via.value} cannot follow a menu.`,
183
+ remediation:
184
+ "Use `--via native-api` (or leave `via` unset), and make sure the router's `api` service is enabled. There is no polling fallback.",
185
+ context: { via: resolved.via.value, capability: "follow" },
186
+ }),
187
+ );
188
+ return;
189
+ }
190
+ const backend = createProtocolAdapter({
191
+ protocol: resolved.via.value,
192
+ host: resolved.target.host,
193
+ port: resolved.target.port,
194
+ tls: resolved.target.tls,
195
+ baseUrl: resolved.target.baseUrl,
196
+ username: resolved.auth.username,
197
+ password: resolved.auth.password,
198
+ timeoutMs: resolved.timeoutMs.value,
199
+ insecure: resolved.insecure?.value,
200
+ });
201
+ let prepared: Awaited<ReturnType<typeof validateFollow>>;
202
+ try {
203
+ prepared = await validateFollow(resolved, backend);
204
+ } catch (error) {
205
+ await backend.close();
206
+ yield buildRetrieveErrorEnvelopeFromResolved(resolved, error);
207
+ return;
208
+ }
209
+ if (options.signal?.aborted) {
210
+ await backend.close();
211
+ yield interruptedSummary(resolved, prepared.validation, startedAt);
212
+ return;
213
+ }
214
+ yield* runFollow(
215
+ resolved,
216
+ backend,
217
+ prepared.validation,
218
+ options,
219
+ startedAt,
220
+ prepared.query,
221
+ );
222
+ }
223
+
224
+ /**
225
+ * The follow-only check: a list menu whose `print` takes `follow-only`
226
+ * (#396 round 1: 0 mismatches over 403 menus on 7.23.7 and 7.24.5). Requested
227
+ * attributes are checked as for a one-shot read. `--no-validate` skips both,
228
+ * and RouterOS then rejects a non-followable `listen` itself.
229
+ */
230
+ async function validateFollow(
231
+ resolved: ResolvedRetrieveRequest,
232
+ backend: ProtocolAdapter,
233
+ ): Promise<Awaited<ReturnType<typeof validateRetrieveRead>>> {
234
+ const prepared = await validateRetrieveRead(resolved, backend);
235
+ if (!resolved.validate.value) {
236
+ return prepared;
237
+ }
238
+ const inspection = prepared.inspection;
239
+ if (!inspection) throw new Error("validated follow has no inspection");
240
+ const followable =
241
+ !inspection.singleton &&
242
+ (
243
+ await inspectArgumentNames(backend, [
244
+ ...pathTokens(resolved.path),
245
+ "print",
246
+ ])
247
+ ).includes("follow-only");
248
+ if (!followable) {
249
+ throw new CentrsError({
250
+ code: "validation/not-followable",
251
+ summary: inspection.singleton
252
+ ? `${resolved.path} is a single record, which RouterOS cannot follow.`
253
+ : `${resolved.path}/print has no \`follow-only\`, so RouterOS cannot follow this menu.`,
254
+ remediation: `Read it once with \`centrs retrieve <router> ${resolved.path}\`, or read it on a timer with \`centrs retrieve <router> ${resolved.path} --sample 5s\`.`,
255
+ context: {
256
+ path: resolved.path,
257
+ singleton: inspection.singleton,
258
+ validationSource: "/console/inspect request=child",
259
+ },
260
+ });
261
+ }
262
+ return prepared;
263
+ }
264
+
265
+ async function* runFollow(
266
+ resolved: ResolvedRetrieveRequest,
267
+ backend: ProtocolAdapter,
268
+ validation: EnvelopeValidationMeta,
269
+ options: RetrieveFollowOptions,
270
+ startedAt: number,
271
+ query?: readonly string[],
272
+ ): AsyncGenerator<RetrieveEnvelope, void, void> {
273
+ const follow = resolved.follow ?? { sweepMs: 0 };
274
+ const bufferLimit = options.bufferLimit ?? FOLLOW_BUFFER_LIMIT;
275
+ const counts: RetrieveFollowCounts = {
276
+ frames: 0,
277
+ snapshot: 0,
278
+ changes: 0,
279
+ sweeps: 0,
280
+ synced: false,
281
+ };
282
+ const tips = followTips(resolved);
283
+ const controller = new AbortController();
284
+ let stopReason: RetrieveFollowStopReason | undefined;
285
+ let cancelUnacknowledged = false;
286
+
287
+ // One wire-ordered queue for every source; `pending` holds listen replies
288
+ // that arrived before the snapshot's `!done` (A1 replays them after it).
289
+ const queue = new FollowQueue();
290
+ const pending: Extract<FollowEvent, { type: "listen" }>[] = [];
291
+ // Versions are advanced in the synchronous wire tap, not when a consumer
292
+ // eventually drains the queue. A delayed membership reply cannot restore a
293
+ // row after a newer change/deletion. Entries leave with their final result.
294
+ const latest = new Map<string, number>();
295
+ const dirty = new Map<string, number>();
296
+ // Fixed at the snapshot's wire !done: newer changes stay live work and
297
+ // cannot continually extend bootstrap or postpone its first sweep.
298
+ const bootstrapPending = new Map<string, number>();
299
+ let version = 0;
300
+ let membershipRunning = false;
301
+ let membershipSize = 0;
302
+ let sweepRunning = false;
303
+ let sweepDue = false;
304
+ let snapshotComplete = false;
305
+ // Cleared once the loop leaves: replies that land during teardown are not
306
+ // read, so they must not count toward the overflow bound. Errors still do.
307
+ let draining = true;
308
+ const push = (event: FollowEvent): void => {
309
+ if (event.type === "error") {
310
+ queue.fail(event.error);
311
+ draining = false;
312
+ controller.abort();
313
+ return;
314
+ }
315
+ if (!draining) return;
316
+ if (
317
+ (event.type === "listen" || event.type === "snapshot-row") &&
318
+ queue.length + pending.length + dirty.size + membershipSize >= bufferLimit
319
+ ) {
320
+ queue.fail(overflowError(bufferLimit));
321
+ draining = false;
322
+ controller.abort();
323
+ return;
324
+ }
325
+ queue.push(event);
326
+ };
327
+ const stop = (reason: RetrieveFollowStopReason): void => {
328
+ stopReason ??= reason;
329
+ queue.requestStop();
330
+ controller.abort();
331
+ };
332
+ /**
333
+ * Cancel the listen and wait for RouterOS's acknowledgement (bounded by
334
+ * the adapter's cancel grace), so the outcome is known before the summary.
335
+ */
336
+ const endListen = async (): Promise<Warning[]> => {
337
+ clearTimeout(durationTimer);
338
+ clearTimeout(sweepTimer);
339
+ controller.abort();
340
+ await Promise.all(running);
341
+ return cancelUnacknowledged ? [cancelUnacknowledgedWarning(resolved)] : [];
342
+ };
343
+ const durationTimer =
344
+ follow.durationMs !== undefined
345
+ ? setTimeout(() => stop("duration-elapsed"), follow.durationMs)
346
+ : undefined;
347
+ const onExternalAbort = (): void => stop("interrupted");
348
+ if (options.signal?.aborted) {
349
+ // Aborting the internal signal too keeps `run` from dispatching anything.
350
+ onExternalAbort();
351
+ controller.abort();
352
+ } else options.signal?.addEventListener("abort", onExternalAbort);
353
+
354
+ const internalProplist =
355
+ resolved.attributes.length > 0
356
+ ? [...new Set([".id", ".dead", ...resolved.attributes])]
357
+ : undefined;
358
+ const detail = resolved.allAttributes ? { detail: "" } : undefined;
359
+ /**
360
+ * Run one native command, feeding the queue from its synchronous reply
361
+ * tap. The generator is only drained here; a failure becomes an `error`.
362
+ */
363
+ const running = new Set<Promise<void>>();
364
+ const run = (
365
+ request: ProtocolApiRequest,
366
+ onReply: (reply: ProtocolTappedReply) => void,
367
+ extra: Partial<ProtocolListenOptions> = {},
368
+ ): void => {
369
+ const drained = (async () => {
370
+ try {
371
+ for await (const _reply of backend.stream(request, {
372
+ ...extra,
373
+ signal: controller.signal,
374
+ onReply,
375
+ })) {
376
+ // Replies reach the queue through `onReply`, in wire order.
377
+ }
378
+ if (request.listen) push({ type: "listen-end" });
379
+ } catch (error) {
380
+ push({ type: "error", error });
381
+ }
382
+ })();
383
+ running.add(drained);
384
+ void drained.then(() => running.delete(drained));
385
+ };
386
+ /** `onReply` for a finite read: rows, then `done` unless it trapped. */
387
+ const finite = (
388
+ onRow: (row: Record<string, string>) => void,
389
+ onDone: () => void,
390
+ ): ((reply: ProtocolTappedReply) => void) => {
391
+ let trapped = false;
392
+ return (reply) => {
393
+ if (reply.type === "re") onRow(reply.attributes);
394
+ else if (reply.type === "trap") trapped = true;
395
+ else if (reply.type === "done" && !trapped) onDone();
396
+ };
397
+ };
398
+
399
+ // The snapshot `print` goes out only once the `listen` is on the wire, on
400
+ // the same connection, so every change after it is in the listen feed.
401
+ run(
402
+ {
403
+ verb: "print",
404
+ path: resolved.path,
405
+ listen: true,
406
+ proplist: internalProplist,
407
+ attributes: detail,
408
+ },
409
+ (reply) => {
410
+ if (reply.type === "re" || reply.type === "empty") {
411
+ const id = reply.attributes[".id"];
412
+ const current = query && id !== undefined ? ++version : undefined;
413
+ if (draining && current !== undefined && id !== undefined)
414
+ latest.set(id, current);
415
+ push({ type: "listen", reply, version: current });
416
+ }
417
+ },
418
+ {
419
+ onListening: () => {
420
+ options.onListening?.();
421
+ run(
422
+ {
423
+ verb: "print",
424
+ path: resolved.path,
425
+ proplist: internalProplist,
426
+ attributes: detail,
427
+ query,
428
+ },
429
+ finite(
430
+ (row) => push({ type: "snapshot-row", row }),
431
+ () =>
432
+ push({
433
+ type: "snapshot-done",
434
+ bootstrap: query ? new Map(latest) : undefined,
435
+ }),
436
+ ),
437
+ );
438
+ },
439
+ onCancelUnacknowledged: () => {
440
+ cancelUnacknowledged = true;
441
+ },
442
+ },
443
+ );
444
+
445
+ // Held ids: what the consumer's state contains if it applied every line.
446
+ const held = new Set<string>();
447
+ // Ids touched by a listen reply since the in-flight sweep was sent; a sweep
448
+ // never removes those (its snapshot may predate them).
449
+ let touchedSinceSweep: Set<string> | undefined;
450
+ let sweepTimer: ReturnType<typeof setTimeout> | undefined;
451
+ const startSweep = (): void => {
452
+ if (
453
+ !sweepDue ||
454
+ membershipRunning ||
455
+ sweepRunning ||
456
+ controller.signal.aborted
457
+ )
458
+ return;
459
+ sweepDue = false;
460
+ sweepRunning = true;
461
+ push({ type: "sweep-start" });
462
+ const ids = new Set<string>();
463
+ run(
464
+ { verb: "print", path: resolved.path, proplist: [".id"], query },
465
+ finite(
466
+ (row) => ids.add(row[".id"] ?? ""),
467
+ () => push({ type: "sweep", ids }),
468
+ ),
469
+ );
470
+ };
471
+ const scheduleSweep = (): void => {
472
+ if (follow.sweepMs <= 0 || controller.signal.aborted) return;
473
+ sweepTimer = setTimeout(() => {
474
+ if (controller.signal.aborted) return;
475
+ sweepDue = true;
476
+ startSweep();
477
+ }, follow.sweepMs);
478
+ };
479
+ /** One bounded batch at a time. The unfiltered listen only identifies ids;
480
+ * the router returns current matching rows with the caller's projection. */
481
+ const scheduleMembership = (): void => {
482
+ if (
483
+ !query ||
484
+ membershipRunning ||
485
+ sweepRunning ||
486
+ sweepDue ||
487
+ dirty.size === 0 ||
488
+ controller.signal.aborted
489
+ )
490
+ return;
491
+ const versions = new Map<string, number>();
492
+ for (const id of bootstrapPending.keys()) {
493
+ const current = dirty.get(id);
494
+ if (current !== undefined) versions.set(id, current);
495
+ if (versions.size === 256) break;
496
+ }
497
+ for (const [id, current] of dirty) {
498
+ if (versions.size === 256) break;
499
+ versions.set(id, current);
500
+ }
501
+ for (const id of versions.keys()) dirty.delete(id);
502
+ const ids = [...versions.keys()];
503
+ const selection = ids.flatMap((id, index) =>
504
+ index === 0 ? [`.id=${id}`] : [`.id=${id}`, "#|"],
505
+ );
506
+ const rows = new Map<string, Record<string, string>>();
507
+ membershipRunning = true;
508
+ membershipSize = versions.size;
509
+ run(
510
+ {
511
+ verb: "print",
512
+ path: resolved.path,
513
+ proplist: internalProplist,
514
+ attributes: detail,
515
+ query: [...selection, ...query, "#&"],
516
+ },
517
+ finite(
518
+ (row) => {
519
+ if (row[".id"] !== undefined) rows.set(row[".id"], row);
520
+ },
521
+ () => push({ type: "membership", versions, rows }),
522
+ ),
523
+ );
524
+ };
525
+
526
+ const frame = (
527
+ phase: FrameMeta["phase"],
528
+ change: FrameMeta["change"],
529
+ id: string,
530
+ source: FrameMeta["source"],
531
+ row: Record<string, string> | null,
532
+ ): RetrieveSuccessEnvelope => {
533
+ counts.frames += 1;
534
+ if (phase === "snapshot") counts.snapshot += 1;
535
+ else counts.changes += 1;
536
+ const data = row === null ? null : projectRow(row, resolved.attributes);
537
+ return streamEnvelope(resolved, validation, data, {
538
+ kind: "frame",
539
+ index: counts.frames,
540
+ phase,
541
+ change,
542
+ id,
543
+ source,
544
+ });
545
+ };
546
+ /** A listen reply as a frame, or `undefined` when it changes nothing held. */
547
+ const applyListen = (
548
+ event: Extract<FollowEvent, { type: "listen" }>,
549
+ phase: FrameMeta["phase"],
550
+ ): RetrieveSuccessEnvelope | undefined => {
551
+ const { reply } = event;
552
+ // `!empty` on a listen is the cancel acknowledgement, never a table state.
553
+ if (reply.type === "empty") return undefined;
554
+ const id = reply.attributes[".id"];
555
+ if (id === undefined) return undefined;
556
+ touchedSinceSweep?.add(id);
557
+ if (query) {
558
+ if (event.version === undefined || event.version !== latest.get(id))
559
+ return undefined;
560
+ if (reply.attributes[".dead"] !== "true") {
561
+ dirty.set(id, event.version);
562
+ return undefined;
563
+ }
564
+ dirty.delete(id);
565
+ latest.delete(id);
566
+ bootstrapPending.delete(id);
567
+ }
568
+ if (reply.attributes[".dead"] === "true") {
569
+ // A `.dead` for an id never reported is a coalesced add+delete.
570
+ if (!held.delete(id)) return undefined;
571
+ return frame(phase, "removed", id, "listen", null);
572
+ }
573
+ held.add(id);
574
+ return frame(phase, "upsert", id, "listen", reply.attributes);
575
+ };
576
+ /**
577
+ * Between frames of one batch (the A1 replay, a sweep's removals): a stop
578
+ * ends the loop and a fatal error is thrown, without waiting for the batch.
579
+ */
580
+ const haltRequested = (): boolean => {
581
+ const halted = queue.halted();
582
+ if (halted?.type === "error") throw halted.error;
583
+ return halted !== undefined;
584
+ };
585
+ const countReached = (): boolean =>
586
+ follow.count !== undefined && counts.changes >= follow.count;
587
+
588
+ try {
589
+ if (tips.length > 0) {
590
+ yield streamEnvelope(
591
+ resolved,
592
+ validation,
593
+ null,
594
+ { kind: "notice" },
595
+ tips,
596
+ );
597
+ }
598
+ loop: for (;;) {
599
+ const event = await queue.next();
600
+ switch (event.type) {
601
+ case "listen": {
602
+ if (!snapshotComplete) {
603
+ pending.push(event);
604
+ break;
605
+ }
606
+ const envelope = applyListen(
607
+ event,
608
+ counts.synced ? "live" : "snapshot",
609
+ );
610
+ if (envelope) {
611
+ yield envelope;
612
+ if (counts.synced && countReached()) {
613
+ stopReason ??= "count-reached";
614
+ break loop;
615
+ }
616
+ }
617
+ break;
618
+ }
619
+ case "snapshot-row": {
620
+ const id = event.row[".id"];
621
+ if (id !== undefined) {
622
+ held.add(id);
623
+ yield frame("snapshot", "upsert", id, "print", event.row);
624
+ }
625
+ break;
626
+ }
627
+ case "snapshot-done": {
628
+ snapshotComplete = true;
629
+ for (const [id, current] of event.bootstrap ?? [])
630
+ bootstrapPending.set(id, current);
631
+ for (const reply of pending.splice(0, pending.length)) {
632
+ if (haltRequested()) break loop;
633
+ const envelope = applyListen(reply, "snapshot");
634
+ if (envelope) yield envelope;
635
+ }
636
+ if (haltRequested()) break loop;
637
+ if (query) break;
638
+ counts.synced = true;
639
+ yield streamEnvelope(resolved, validation, null, {
640
+ kind: "synced",
641
+ rows: held.size,
642
+ });
643
+ scheduleSweep();
644
+ break;
645
+ }
646
+ case "membership": {
647
+ membershipRunning = false;
648
+ for (const [id, expected] of event.versions) {
649
+ if (haltRequested()) break loop;
650
+ membershipSize--;
651
+ const initial = bootstrapPending.get(id);
652
+ if (initial !== undefined && expected >= initial)
653
+ bootstrapPending.delete(id);
654
+ // A newer wire version invalidates this result, but it is
655
+ // live work after the fixed bootstrap cutoff, not a reason
656
+ // to extend initialization. Its dirty entry is retained.
657
+ if (latest.get(id) !== expected) continue;
658
+ latest.delete(id);
659
+ const row = event.rows.get(id);
660
+ const phase = counts.synced ? "live" : "snapshot";
661
+ if (row) {
662
+ held.add(id);
663
+ yield frame(phase, "upsert", id, "membership", row);
664
+ } else if (held.delete(id)) {
665
+ yield frame(phase, "removed", id, "membership", null);
666
+ }
667
+ if (counts.synced && countReached()) {
668
+ stopReason ??= "count-reached";
669
+ break loop;
670
+ }
671
+ }
672
+ break;
673
+ }
674
+ case "sweep-start":
675
+ touchedSinceSweep = new Set();
676
+ break;
677
+ case "sweep": {
678
+ sweepRunning = false;
679
+ counts.sweeps += 1;
680
+ const touched = touchedSinceSweep ?? new Set<string>();
681
+ touchedSinceSweep = undefined;
682
+ for (const id of [...held]) {
683
+ if (
684
+ event.ids.has(id) ||
685
+ touched.has(id) ||
686
+ (query && latest.has(id))
687
+ )
688
+ continue;
689
+ if (haltRequested()) break loop;
690
+ held.delete(id);
691
+ yield frame(
692
+ "live",
693
+ "removed",
694
+ id,
695
+ query ? "membership" : "sweep",
696
+ null,
697
+ );
698
+ if (countReached()) {
699
+ stopReason ??= "count-reached";
700
+ break loop;
701
+ }
702
+ }
703
+ if (haltRequested()) break loop;
704
+ scheduleSweep();
705
+ break;
706
+ }
707
+ case "listen-end":
708
+ stopReason ??= "completed";
709
+ break loop;
710
+ case "stop":
711
+ break loop;
712
+ case "error":
713
+ throw event.error;
714
+ }
715
+ if (query) {
716
+ if (haltRequested()) break;
717
+ if (snapshotComplete && !counts.synced && bootstrapPending.size === 0) {
718
+ counts.synced = true;
719
+ yield streamEnvelope(resolved, validation, null, {
720
+ kind: "synced",
721
+ rows: held.size,
722
+ });
723
+ scheduleSweep();
724
+ }
725
+ startSweep();
726
+ scheduleMembership();
727
+ }
728
+ }
729
+ draining = false;
730
+ const warnings = await endListen();
731
+ // A command that failed while teardown waited (a sweep or snapshot trap
732
+ // after `--count`/`--duration`) fails the follow, not a clean summary.
733
+ const late = queue.fatalError;
734
+ if (late) throw late.error;
735
+ yield summaryEnvelope(
736
+ resolved,
737
+ validation,
738
+ { stopReason: stopReason ?? "completed", ...counts },
739
+ Date.now() - startedAt,
740
+ tips,
741
+ warnings,
742
+ );
743
+ } catch (error) {
744
+ draining = false;
745
+ const warnings = await endListen();
746
+ const envelope = failedStreamEnvelope(
747
+ resolved,
748
+ validation,
749
+ error,
750
+ (stopReason) => ({
751
+ kind: "summary",
752
+ stopReason,
753
+ ...counts,
754
+ durationMs: Date.now() - startedAt,
755
+ }),
756
+ counts.frames,
757
+ );
758
+ envelope.warnings = [...envelope.warnings, ...warnings];
759
+ envelope.tips = [...envelope.tips, ...tips];
760
+ yield envelope;
761
+ } finally {
762
+ options.signal?.removeEventListener("abort", onExternalAbort);
763
+ // A consumer that stopped reading early lands here directly.
764
+ await endListen();
765
+ await backend.close();
766
+ }
767
+ }
768
+
769
+ function summaryEnvelope(
770
+ resolved: ResolvedRetrieveRequest,
771
+ validation: EnvelopeValidationMeta,
772
+ counts: Omit<RetrieveFollowSummary, "durationMs">,
773
+ durationMs: number,
774
+ tips: Tip[],
775
+ warnings: readonly Warning[],
776
+ ): RetrieveSuccessEnvelope {
777
+ const summary: RetrieveFollowSummary = { ...counts, durationMs };
778
+ const envelope = streamEnvelope(
779
+ resolved,
780
+ validation,
781
+ summary,
782
+ { kind: "summary", ...summary },
783
+ tips,
784
+ );
785
+ if (envelope.meta.operation) {
786
+ envelope.meta.operation.objectCount = counts.frames;
787
+ }
788
+ envelope.warnings = [...resolved.warnings, ...warnings];
789
+ return envelope;
790
+ }
791
+
792
+ /** The summary of a follow cancelled before its listen went out. */
793
+ function interruptedSummary(
794
+ resolved: ResolvedRetrieveRequest,
795
+ validation: EnvelopeValidationMeta,
796
+ startedAt: number,
797
+ ): RetrieveSuccessEnvelope {
798
+ return summaryEnvelope(
799
+ resolved,
800
+ validation,
801
+ {
802
+ stopReason: "interrupted",
803
+ frames: 0,
804
+ snapshot: 0,
805
+ changes: 0,
806
+ sweeps: 0,
807
+ synced: false,
808
+ },
809
+ Date.now() - startedAt,
810
+ [],
811
+ [],
812
+ );
813
+ }
814
+
815
+ /** Advice known before the first frame: what this follow will not report. */
816
+ function followTips(resolved: ResolvedRetrieveRequest): Tip[] {
817
+ if ((resolved.follow?.sweepMs ?? 0) > 0) return [];
818
+ return [
819
+ buildTip(
820
+ "tip/follow-sweep-off",
821
+ "`--sweep 0` turned off the membership sweep, so removals RouterOS does not send (view menus such as `/interface/<type>`, `/ip/route`, `/ipv6/route`) will not be reported." +
822
+ (resolved.query
823
+ ? " Silent exits from the predicate will also remain held until a listen notification arrives."
824
+ : ""),
825
+ "Leave `--sweep` at its default (10s), or follow the base menu (`/interface`, `/routing/route`), which reports its removals.",
826
+ ),
827
+ ];
828
+ }
829
+
830
+ /** `data` limited to the requested attributes; `.id`/`.dead` only when named. */
831
+ function projectRow(
832
+ row: Record<string, string>,
833
+ attributes: readonly string[],
834
+ ): Record<string, string> {
835
+ if (attributes.length === 0) return row;
836
+ const projected: Record<string, string> = {};
837
+ for (const attribute of attributes) {
838
+ const value = row[attribute];
839
+ if (value !== undefined) projected[attribute] = value;
840
+ }
841
+ return projected;
842
+ }
843
+
844
+ function overflowError(limit: number): CentrsError {
845
+ return new CentrsError({
846
+ code: "transport/stream-overflow",
847
+ summary: `More than ${limit} changes were waiting unread, so centrs ended the follow instead of dropping any.`,
848
+ remediation:
849
+ "The state you hold is stale: follow again to take a new snapshot, and read lines as they arrive (or narrow the menu).",
850
+ context: { bufferLimit: limit },
851
+ });
852
+ }
853
+
854
+ function cancelUnacknowledgedWarning(
855
+ resolved: ResolvedRetrieveRequest,
856
+ ): Warning {
857
+ return {
858
+ code: "transport/cancel-unacknowledged",
859
+ message: `RouterOS did not acknowledge /cancel within ${resolved.timeoutMs.value}ms; centrs closed the session locally.`,
860
+ context: { timeoutMs: resolved.timeoutMs.value },
861
+ };
862
+ }