@tikoci/centrs 0.1.6 → 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.
- package/CHANGELOG.md +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +84 -18
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `retrieve --sample <interval>`: read a menu again every interval, one line
|
|
3
|
+
* per complete read. The contract is `commands/retrieve/README.md` → Sample.
|
|
4
|
+
*
|
|
5
|
+
* Each sample is an ordinary one-shot read (`print` or `get`), so its end is
|
|
6
|
+
* the read's own `!done` (native) or HTTP response (REST). RouterOS's
|
|
7
|
+
* `print interval=` was measured and not used: it has no end-of-tick marker,
|
|
8
|
+
* so a tick is only known complete when the next one starts, and a tick
|
|
9
|
+
* heavier than its interval saturates the connection (CHR 7.24.5, 2026-10-08).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { EnvelopeValidationMeta } from "./core/envelope.ts";
|
|
13
|
+
import { CentrsError } from "./errors.ts";
|
|
14
|
+
import { createProtocolAdapter } from "./protocols/adapter.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildRetrieveErrorEnvelope,
|
|
17
|
+
buildRetrieveErrorEnvelopeFromResolved,
|
|
18
|
+
executeRetrieve,
|
|
19
|
+
type ResolvedRetrieveRequest,
|
|
20
|
+
type RetrieveEnvelope,
|
|
21
|
+
type RetrieveRequest,
|
|
22
|
+
type RetrieveSampleStopReason,
|
|
23
|
+
type RetrieveSampleSummary,
|
|
24
|
+
type RetrieveSuccessEnvelope,
|
|
25
|
+
resolveRetrieveRequest,
|
|
26
|
+
validateRetrieveRead,
|
|
27
|
+
} from "./retrieve.ts";
|
|
28
|
+
import { failedStreamEnvelope, streamEnvelope } from "./retrieve-stream.ts";
|
|
29
|
+
|
|
30
|
+
export interface RetrieveSampleOptions {
|
|
31
|
+
/** Ctrl-C: ends the sample with `stopReason: "interrupted"`. */
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sample one menu: yields a `sample` line per complete read, then exactly one
|
|
37
|
+
* summary (successful, or an error envelope carrying the partial count).
|
|
38
|
+
* Errors before the first read (resolution, validation) yield one error
|
|
39
|
+
* envelope without a summary.
|
|
40
|
+
*/
|
|
41
|
+
export async function* retrieveSample(
|
|
42
|
+
request: RetrieveRequest,
|
|
43
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
44
|
+
options: RetrieveSampleOptions = {},
|
|
45
|
+
): AsyncGenerator<RetrieveEnvelope, void, void> {
|
|
46
|
+
if (request.wait !== undefined) {
|
|
47
|
+
yield buildRetrieveErrorEnvelope(
|
|
48
|
+
request,
|
|
49
|
+
new CentrsError({
|
|
50
|
+
code: "input/invalid-command",
|
|
51
|
+
summary:
|
|
52
|
+
"`--sample` with a `--wait` deadline is a bounded waiter, not a sample stream.",
|
|
53
|
+
remediation:
|
|
54
|
+
"Consume the final result with `retrieveWait()` or `centrs retrieve … --wait <deadline> --sample <interval>`.",
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (request.sample === undefined) {
|
|
60
|
+
yield buildRetrieveErrorEnvelope(
|
|
61
|
+
request,
|
|
62
|
+
new CentrsError({
|
|
63
|
+
code: "input/invalid-command",
|
|
64
|
+
summary: "`retrieveSample()` needs `sample`, the time between reads.",
|
|
65
|
+
remediation:
|
|
66
|
+
"Set `sample` on the request, e.g. `{ path: '/interface', sample: '5s' }`.",
|
|
67
|
+
context: { capability: "sample" },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let resolved: ResolvedRetrieveRequest;
|
|
73
|
+
try {
|
|
74
|
+
resolved = await resolveRetrieveRequest(request, env);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
yield buildRetrieveErrorEnvelope(request, error);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
if (options.signal?.aborted) {
|
|
81
|
+
yield summaryEnvelope(
|
|
82
|
+
resolved,
|
|
83
|
+
{ enabled: resolved.validate.value, result: "skipped" },
|
|
84
|
+
{ stopReason: "interrupted", samples: 0 },
|
|
85
|
+
startedAt,
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const backend = createProtocolAdapter({
|
|
90
|
+
protocol: resolved.via.value,
|
|
91
|
+
host: resolved.target.host,
|
|
92
|
+
port: resolved.target.port,
|
|
93
|
+
tls: resolved.target.tls,
|
|
94
|
+
baseUrl: resolved.target.baseUrl,
|
|
95
|
+
username: resolved.auth.username,
|
|
96
|
+
password: resolved.auth.password,
|
|
97
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
98
|
+
insecure: resolved.insecure?.value,
|
|
99
|
+
});
|
|
100
|
+
const sample = resolved.sample ?? { intervalMs: 0 };
|
|
101
|
+
let samples = 0;
|
|
102
|
+
let stopReason: RetrieveSampleStopReason | undefined;
|
|
103
|
+
// Every stop source aborts this signal. It abandons the read in flight
|
|
104
|
+
// (REST aborts its request; a native read ends when the session closes
|
|
105
|
+
// below) and ends the wait between reads.
|
|
106
|
+
const abandon = new AbortController();
|
|
107
|
+
const stop = (reason: RetrieveSampleStopReason): void => {
|
|
108
|
+
stopReason ??= reason;
|
|
109
|
+
abandon.abort();
|
|
110
|
+
};
|
|
111
|
+
const onAbort = (): void => stop("interrupted");
|
|
112
|
+
options.signal?.addEventListener("abort", onAbort);
|
|
113
|
+
let durationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
114
|
+
try {
|
|
115
|
+
let prepared: Awaited<ReturnType<typeof validateRetrieveRead>>;
|
|
116
|
+
try {
|
|
117
|
+
prepared = await validateRetrieveRead(resolved, backend);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
yield buildRetrieveErrorEnvelopeFromResolved(resolved, error);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const { validation, ...plan } = prepared;
|
|
123
|
+
if (sample.durationMs !== undefined) {
|
|
124
|
+
durationTimer = setTimeout(
|
|
125
|
+
() => stop("duration-elapsed"),
|
|
126
|
+
sample.durationMs,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
while (stopReason === undefined) {
|
|
131
|
+
const sentAt = Date.now();
|
|
132
|
+
const read = executeRetrieve(
|
|
133
|
+
resolved,
|
|
134
|
+
backend,
|
|
135
|
+
plan,
|
|
136
|
+
abandon.signal,
|
|
137
|
+
).then((data) => ({ data }));
|
|
138
|
+
// A read abandoned by a stop may still fail later; nobody awaits it.
|
|
139
|
+
read.catch(() => {});
|
|
140
|
+
const result = await unlessAborted(read, abandon.signal);
|
|
141
|
+
// A read still in flight at the stop is not a complete sample.
|
|
142
|
+
if (result === undefined) break;
|
|
143
|
+
samples += 1;
|
|
144
|
+
yield streamEnvelope(resolved, validation, result.data, {
|
|
145
|
+
kind: "sample",
|
|
146
|
+
index: samples,
|
|
147
|
+
at: new Date(sentAt).toISOString(),
|
|
148
|
+
readMs: Date.now() - sentAt,
|
|
149
|
+
});
|
|
150
|
+
if (sample.count !== undefined && samples >= sample.count) {
|
|
151
|
+
stopReason ??= "count-reached";
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
// Start to start; a read slower than the interval delays the next
|
|
155
|
+
// one instead of overlapping it or bursting to catch up.
|
|
156
|
+
const waitMs = sentAt + sample.intervalMs - Date.now();
|
|
157
|
+
if (waitMs > 0) {
|
|
158
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
159
|
+
await unlessAborted(
|
|
160
|
+
new Promise<void>((resolve) => {
|
|
161
|
+
timer = setTimeout(resolve, waitMs);
|
|
162
|
+
}),
|
|
163
|
+
abandon.signal,
|
|
164
|
+
);
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
yield failedStreamEnvelope(
|
|
170
|
+
resolved,
|
|
171
|
+
validation,
|
|
172
|
+
error,
|
|
173
|
+
(failure) => ({
|
|
174
|
+
kind: "summary",
|
|
175
|
+
stopReason: failure,
|
|
176
|
+
samples,
|
|
177
|
+
durationMs: Date.now() - startedAt,
|
|
178
|
+
}),
|
|
179
|
+
samples,
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
yield summaryEnvelope(
|
|
184
|
+
resolved,
|
|
185
|
+
validation,
|
|
186
|
+
{ stopReason: stopReason ?? "interrupted", samples },
|
|
187
|
+
startedAt,
|
|
188
|
+
);
|
|
189
|
+
} finally {
|
|
190
|
+
// A consumer that stopped reading early lands here directly.
|
|
191
|
+
abandon.abort();
|
|
192
|
+
clearTimeout(durationTimer);
|
|
193
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
194
|
+
await backend.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `work`'s value, or `undefined` as soon as `signal` aborts. The abort
|
|
200
|
+
* listener is removed when `work` settles: racing every sample against one
|
|
201
|
+
* long-lived promise would keep a reaction per sample for the whole run
|
|
202
|
+
* (#411 review: about 5,300 retained after 3,000 samples).
|
|
203
|
+
*/
|
|
204
|
+
function unlessAborted<T>(
|
|
205
|
+
work: Promise<T>,
|
|
206
|
+
signal: AbortSignal,
|
|
207
|
+
): Promise<T | undefined> {
|
|
208
|
+
if (signal.aborted) return Promise.resolve(undefined);
|
|
209
|
+
return new Promise<T | undefined>((resolve, reject) => {
|
|
210
|
+
const onAbort = (): void => resolve(undefined);
|
|
211
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
212
|
+
work.then(
|
|
213
|
+
(value) => {
|
|
214
|
+
signal.removeEventListener("abort", onAbort);
|
|
215
|
+
resolve(value);
|
|
216
|
+
},
|
|
217
|
+
(error: unknown) => {
|
|
218
|
+
signal.removeEventListener("abort", onAbort);
|
|
219
|
+
reject(error);
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function summaryEnvelope(
|
|
226
|
+
resolved: ResolvedRetrieveRequest,
|
|
227
|
+
validation: EnvelopeValidationMeta,
|
|
228
|
+
counts: Omit<RetrieveSampleSummary, "durationMs">,
|
|
229
|
+
startedAt: number,
|
|
230
|
+
): RetrieveSuccessEnvelope {
|
|
231
|
+
const summary: RetrieveSampleSummary = {
|
|
232
|
+
...counts,
|
|
233
|
+
durationMs: Date.now() - startedAt,
|
|
234
|
+
};
|
|
235
|
+
const envelope = streamEnvelope(resolved, validation, summary, {
|
|
236
|
+
kind: "summary",
|
|
237
|
+
...summary,
|
|
238
|
+
});
|
|
239
|
+
if (envelope.meta.operation) {
|
|
240
|
+
envelope.meta.operation.objectCount = counts.samples;
|
|
241
|
+
}
|
|
242
|
+
envelope.warnings = [...resolved.warnings];
|
|
243
|
+
return envelope;
|
|
244
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line helpers shared by the two streaming forms of `retrieve`: `--follow`
|
|
3
|
+
* (`src/retrieve-follow.ts`) and `--sample` (`src/retrieve-sample.ts`). Each
|
|
4
|
+
* line is one envelope whose `meta.operation.stream` says what it is
|
|
5
|
+
* (`commands/retrieve/README.md` → Follow, Sample).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EnvelopeValidationMeta, Tip } from "./core/envelope.ts";
|
|
9
|
+
import { formatCentrsErrorLine } from "./errors.ts";
|
|
10
|
+
import {
|
|
11
|
+
buildRetrieveErrorEnvelopeFromResolved,
|
|
12
|
+
metaFromResolved,
|
|
13
|
+
type ResolvedRetrieveRequest,
|
|
14
|
+
type RetrieveEnvelope,
|
|
15
|
+
type RetrieveErrorEnvelope,
|
|
16
|
+
type RetrieveOutputFormat,
|
|
17
|
+
type RetrieveStreamMeta,
|
|
18
|
+
type RetrieveSuccessEnvelope,
|
|
19
|
+
retrieveRequestSummary,
|
|
20
|
+
} from "./retrieve.ts";
|
|
21
|
+
|
|
22
|
+
/** One stream line's envelope: `data` plus its `meta.operation.stream` marker. */
|
|
23
|
+
export function streamEnvelope(
|
|
24
|
+
resolved: ResolvedRetrieveRequest,
|
|
25
|
+
validation: EnvelopeValidationMeta,
|
|
26
|
+
data: unknown,
|
|
27
|
+
stream: RetrieveStreamMeta,
|
|
28
|
+
tips: Tip[] = [],
|
|
29
|
+
): RetrieveSuccessEnvelope {
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
data,
|
|
33
|
+
warnings: [],
|
|
34
|
+
tips,
|
|
35
|
+
meta: metaFromResolved(resolved, validation, {
|
|
36
|
+
kind: "data",
|
|
37
|
+
objectCount: data === null ? 0 : Array.isArray(data) ? data.length : 1,
|
|
38
|
+
request: retrieveRequestSummary(resolved),
|
|
39
|
+
auth: {
|
|
40
|
+
username: resolved.auth.username,
|
|
41
|
+
passwordProvided: resolved.auth.passwordProvided,
|
|
42
|
+
},
|
|
43
|
+
stream,
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The failed summary that ends a stream after it started: the error, plus the
|
|
50
|
+
* partial counts as a `summary` marker. `stopReason` is `routeros-error` when
|
|
51
|
+
* RouterOS answered with an error and `transport-error` for anything else
|
|
52
|
+
* (the same split as `api --stream`); `error.code` names the cause.
|
|
53
|
+
*/
|
|
54
|
+
export function failedStreamEnvelope(
|
|
55
|
+
resolved: ResolvedRetrieveRequest,
|
|
56
|
+
validation: EnvelopeValidationMeta,
|
|
57
|
+
error: unknown,
|
|
58
|
+
summary: (
|
|
59
|
+
stopReason: "routeros-error" | "transport-error",
|
|
60
|
+
) => Extract<RetrieveStreamMeta, { kind: "summary" }>,
|
|
61
|
+
objectCount: number,
|
|
62
|
+
): RetrieveErrorEnvelope {
|
|
63
|
+
const envelope = buildRetrieveErrorEnvelopeFromResolved(resolved, error);
|
|
64
|
+
envelope.meta.validation = validation;
|
|
65
|
+
if (envelope.meta.operation) {
|
|
66
|
+
envelope.meta.operation.objectCount = objectCount;
|
|
67
|
+
envelope.meta.operation.stream = summary(
|
|
68
|
+
envelope.error.code.startsWith("routeros/")
|
|
69
|
+
? "routeros-error"
|
|
70
|
+
: "transport-error",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return envelope;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One stream envelope as one line: compact NDJSON for every structured format
|
|
78
|
+
* (a multi-line YAML document cannot be a stream line), and a short row for
|
|
79
|
+
* `text`. Router text is JSON-escaped so it cannot carry terminal control
|
|
80
|
+
* bytes.
|
|
81
|
+
*/
|
|
82
|
+
export function renderRetrieveStreamLine(
|
|
83
|
+
envelope: RetrieveEnvelope,
|
|
84
|
+
format: RetrieveOutputFormat,
|
|
85
|
+
): string {
|
|
86
|
+
if (format !== "text") return JSON.stringify(envelope);
|
|
87
|
+
if (!envelope.ok) return formatCentrsErrorLine(envelope.error);
|
|
88
|
+
const stream = envelope.meta.operation?.stream;
|
|
89
|
+
switch (stream?.kind) {
|
|
90
|
+
case "notice":
|
|
91
|
+
return envelope.tips
|
|
92
|
+
.map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
|
|
93
|
+
.join("\n");
|
|
94
|
+
case "frame": {
|
|
95
|
+
const head = `${stream.index}\t${stream.phase}\t${stream.change}\t${escaped(stream.id)}`;
|
|
96
|
+
return stream.change === "removed"
|
|
97
|
+
? `${head}\t(${stream.source})`
|
|
98
|
+
: `${head}\t${JSON.stringify(envelope.data)}`;
|
|
99
|
+
}
|
|
100
|
+
case "synced":
|
|
101
|
+
return `— synced: ${stream.rows} row(s)`;
|
|
102
|
+
case "sample":
|
|
103
|
+
return `${stream.index}\t${stream.at}\t${JSON.stringify(envelope.data)}`;
|
|
104
|
+
case "summary":
|
|
105
|
+
return "samples" in stream
|
|
106
|
+
? `— ${stream.stopReason}: ${stream.samples} sample(s) in ${stream.durationMs}ms`
|
|
107
|
+
: `— ${stream.stopReason}: ${stream.snapshot} snapshot, ${stream.changes} change(s), ${stream.sweeps} sweep(s) in ${stream.durationMs}ms`;
|
|
108
|
+
default:
|
|
109
|
+
return JSON.stringify(envelope.data);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function escaped(text: string): string {
|
|
114
|
+
return JSON.stringify(text).slice(1, -1);
|
|
115
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/** Bounded readiness/condition reads; the router evaluates the shared predicate. */
|
|
2
|
+
import { parseQueries, queryErrorForFlag } from "./core/query.ts";
|
|
3
|
+
import { CentrsError } from "./errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
createProtocolAdapter,
|
|
6
|
+
type ProtocolAdapter,
|
|
7
|
+
} from "./protocols/adapter.ts";
|
|
8
|
+
import {
|
|
9
|
+
applyMaxResultsBudget,
|
|
10
|
+
buildRetrieveErrorEnvelope,
|
|
11
|
+
buildRetrieveErrorEnvelopeFromResolved,
|
|
12
|
+
buildSuccessEnvelope,
|
|
13
|
+
executeRetrieve,
|
|
14
|
+
isKnownSingletonPath,
|
|
15
|
+
type ResolvedRetrieveRequest,
|
|
16
|
+
type RetrieveEnvelope,
|
|
17
|
+
type RetrieveRequest,
|
|
18
|
+
type RetrieveWaitMeta,
|
|
19
|
+
resolveRetrieveRequest,
|
|
20
|
+
validateRetrieveRead,
|
|
21
|
+
} from "./retrieve.ts";
|
|
22
|
+
|
|
23
|
+
export interface RetrieveWaitOptions {
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One final envelope, including attempts/observations on failure. No watch frames. */
|
|
28
|
+
export async function retrieveWait(
|
|
29
|
+
request: RetrieveRequest,
|
|
30
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
31
|
+
options: RetrieveWaitOptions = {},
|
|
32
|
+
): Promise<RetrieveEnvelope> {
|
|
33
|
+
let resolved: ResolvedRetrieveRequest;
|
|
34
|
+
try {
|
|
35
|
+
resolved = await resolveRetrieveRequest(request, env);
|
|
36
|
+
if (!resolved.wait)
|
|
37
|
+
throw new CentrsError({
|
|
38
|
+
code: "usage/conflicting-flags",
|
|
39
|
+
summary: "`retrieveWait()` needs a `wait` deadline.",
|
|
40
|
+
remediation:
|
|
41
|
+
"Set `wait` to an overall duration, e.g. `{ path: '/system/resource', wait: '30s' }`.",
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return buildRetrieveErrorEnvelope(request, error);
|
|
45
|
+
}
|
|
46
|
+
const settings = resolved.wait;
|
|
47
|
+
const startedAt = performance.now();
|
|
48
|
+
const deadline = startedAt + settings.deadlineMs;
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
let stopped: "deadline-elapsed" | "interrupted" | undefined;
|
|
51
|
+
const stop = (reason: typeof stopped) => {
|
|
52
|
+
stopped ??= reason;
|
|
53
|
+
controller.abort();
|
|
54
|
+
};
|
|
55
|
+
const onAbort = () => stop("interrupted");
|
|
56
|
+
if (options.signal?.aborted) onAbort();
|
|
57
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
58
|
+
// Long waits avoid setTimeout's 32-bit overflow; the loop also checks time
|
|
59
|
+
// before dispatch and after each completed observation.
|
|
60
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
const armDeadline = () => {
|
|
62
|
+
const remaining = deadline - performance.now();
|
|
63
|
+
if (remaining <= 0) stop("deadline-elapsed");
|
|
64
|
+
else
|
|
65
|
+
deadlineTimer = setTimeout(
|
|
66
|
+
armDeadline,
|
|
67
|
+
Math.min(remaining, 2_147_483_647),
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
armDeadline();
|
|
71
|
+
let attempts = 0;
|
|
72
|
+
let observations = 0;
|
|
73
|
+
let backend: ProtocolAdapter | undefined;
|
|
74
|
+
let prepared: Awaited<ReturnType<typeof validateRetrieveRead>> | undefined;
|
|
75
|
+
let lastError: unknown;
|
|
76
|
+
const meta = (
|
|
77
|
+
stopReason: RetrieveWaitMeta["stopReason"],
|
|
78
|
+
): RetrieveWaitMeta => ({
|
|
79
|
+
stopReason,
|
|
80
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
81
|
+
attempts,
|
|
82
|
+
observations,
|
|
83
|
+
});
|
|
84
|
+
const failure = (
|
|
85
|
+
error: unknown,
|
|
86
|
+
reason: RetrieveWaitMeta["stopReason"],
|
|
87
|
+
): RetrieveEnvelope => {
|
|
88
|
+
// The combined device predicate retains selection semantics, but a
|
|
89
|
+
// diagnostic must identify the option that supplied the bad property.
|
|
90
|
+
if (
|
|
91
|
+
error instanceof CentrsError &&
|
|
92
|
+
(error.context?.["flag"] === "--query" ||
|
|
93
|
+
(error.code === "input/invalid-query" &&
|
|
94
|
+
Array.isArray(error.context?.["bareNames"]))) &&
|
|
95
|
+
settings.until.length > 0 &&
|
|
96
|
+
(!resolved.query ||
|
|
97
|
+
(error.context?.["parameter"] !== undefined &&
|
|
98
|
+
!resolved.query.parsed.names.includes(
|
|
99
|
+
String(error.context["parameter"]),
|
|
100
|
+
)) ||
|
|
101
|
+
(Array.isArray(error.context?.["bareNames"]) &&
|
|
102
|
+
error.context["bareNames"].every(
|
|
103
|
+
(name) => !resolved.query?.parsed.bareNames.includes(String(name)),
|
|
104
|
+
)))
|
|
105
|
+
)
|
|
106
|
+
error = queryErrorForFlag(error, "--until");
|
|
107
|
+
const envelope = buildRetrieveErrorEnvelopeFromResolved(resolved, error);
|
|
108
|
+
if (prepared) envelope.meta.validation = prepared.validation;
|
|
109
|
+
else if (attempts === 0)
|
|
110
|
+
envelope.meta.validation = {
|
|
111
|
+
enabled: resolved.validate.value,
|
|
112
|
+
result: "skipped",
|
|
113
|
+
};
|
|
114
|
+
if (envelope.meta.operation) envelope.meta.operation.wait = meta(reason);
|
|
115
|
+
return envelope;
|
|
116
|
+
};
|
|
117
|
+
const condition = settings.until.length > 0 || settings.untilEmpty;
|
|
118
|
+
const expressions = [
|
|
119
|
+
...(resolved.query?.expressions ?? []),
|
|
120
|
+
...settings.until,
|
|
121
|
+
];
|
|
122
|
+
const observationRequest =
|
|
123
|
+
settings.until.length > 0
|
|
124
|
+
? {
|
|
125
|
+
...resolved,
|
|
126
|
+
query: { expressions, parsed: parseQueries(expressions) },
|
|
127
|
+
}
|
|
128
|
+
: resolved;
|
|
129
|
+
try {
|
|
130
|
+
while (!stopped) {
|
|
131
|
+
if (performance.now() >= deadline) {
|
|
132
|
+
stop("deadline-elapsed");
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const sentAt = performance.now();
|
|
136
|
+
attempts++;
|
|
137
|
+
try {
|
|
138
|
+
backend ??= createProtocolAdapter({
|
|
139
|
+
protocol: resolved.via.value,
|
|
140
|
+
host: resolved.target.host,
|
|
141
|
+
port: resolved.target.port,
|
|
142
|
+
tls: resolved.target.tls,
|
|
143
|
+
baseUrl: resolved.target.baseUrl,
|
|
144
|
+
username: resolved.auth.username,
|
|
145
|
+
password: resolved.auth.password,
|
|
146
|
+
timeoutMs: Math.max(
|
|
147
|
+
1,
|
|
148
|
+
Math.min(resolved.timeoutMs.value, Math.ceil(deadline - sentAt)),
|
|
149
|
+
),
|
|
150
|
+
insecure: resolved.insecure?.value,
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
});
|
|
153
|
+
prepared ??= await validateRetrieveRead(observationRequest, backend);
|
|
154
|
+
if (
|
|
155
|
+
settings.untilEmpty &&
|
|
156
|
+
(prepared.inspection?.singleton ??
|
|
157
|
+
isKnownSingletonPath(observationRequest.path))
|
|
158
|
+
)
|
|
159
|
+
throw new CentrsError({
|
|
160
|
+
code: "usage/conflicting-flags",
|
|
161
|
+
summary: "`--until-empty` needs a list menu, not a singleton.",
|
|
162
|
+
remediation:
|
|
163
|
+
"Use `--wait` alone for singleton readiness, or select a list menu for a zero-row condition.",
|
|
164
|
+
});
|
|
165
|
+
if (stopped) break;
|
|
166
|
+
const data = await executeRetrieve(
|
|
167
|
+
observationRequest,
|
|
168
|
+
backend,
|
|
169
|
+
prepared,
|
|
170
|
+
controller.signal,
|
|
171
|
+
);
|
|
172
|
+
if (performance.now() >= deadline) stop("deadline-elapsed");
|
|
173
|
+
if (stopped) break;
|
|
174
|
+
observations++;
|
|
175
|
+
lastError = undefined;
|
|
176
|
+
const met =
|
|
177
|
+
!condition ||
|
|
178
|
+
(Array.isArray(data) &&
|
|
179
|
+
(settings.untilEmpty ? data.length === 0 : data.length > 0));
|
|
180
|
+
if (met) {
|
|
181
|
+
const envelope = buildSuccessEnvelope(
|
|
182
|
+
resolved,
|
|
183
|
+
{ kind: "data", data },
|
|
184
|
+
prepared.validation,
|
|
185
|
+
resolved.warnings,
|
|
186
|
+
);
|
|
187
|
+
if (envelope.meta.operation)
|
|
188
|
+
envelope.meta.operation.wait = meta(
|
|
189
|
+
condition ? "condition-met" : "ready",
|
|
190
|
+
);
|
|
191
|
+
return applyMaxResultsBudget(envelope);
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (stopped) break;
|
|
195
|
+
if (!isRetryableWaitError(error)) return failure(error, "failed");
|
|
196
|
+
lastError = error;
|
|
197
|
+
await backend?.close();
|
|
198
|
+
backend = undefined;
|
|
199
|
+
prepared = undefined;
|
|
200
|
+
}
|
|
201
|
+
await delay(
|
|
202
|
+
Math.max(0, sentAt + settings.intervalMs - performance.now()),
|
|
203
|
+
controller.signal,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (stopped === "interrupted")
|
|
207
|
+
return failure(
|
|
208
|
+
new CentrsError({
|
|
209
|
+
code: "wait/interrupted",
|
|
210
|
+
summary:
|
|
211
|
+
"The wait was cancelled before readiness or its condition was established.",
|
|
212
|
+
remediation:
|
|
213
|
+
"Run the wait again when you want to continue; cancellation is not evidence that the condition holds.",
|
|
214
|
+
}),
|
|
215
|
+
"interrupted",
|
|
216
|
+
);
|
|
217
|
+
return failure(
|
|
218
|
+
lastError ??
|
|
219
|
+
new CentrsError({
|
|
220
|
+
code: "wait/deadline-exceeded",
|
|
221
|
+
summary: condition
|
|
222
|
+
? "The wait deadline passed without a completed observation satisfying the condition."
|
|
223
|
+
: "The wait deadline passed before a read completed successfully.",
|
|
224
|
+
remediation:
|
|
225
|
+
"Check the router's current state and the predicate, or choose a longer `--wait` deadline.",
|
|
226
|
+
context: { deadlineMs: settings.deadlineMs },
|
|
227
|
+
}),
|
|
228
|
+
"deadline-elapsed",
|
|
229
|
+
);
|
|
230
|
+
} finally {
|
|
231
|
+
controller.abort();
|
|
232
|
+
clearTimeout(deadlineTimer);
|
|
233
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
234
|
+
await backend?.close();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const TRANSIENT_CAUSES = new Set([
|
|
239
|
+
"ECONNRESET",
|
|
240
|
+
"ConnectionReset",
|
|
241
|
+
"EHOSTDOWN",
|
|
242
|
+
"EHOSTUNREACH",
|
|
243
|
+
"ENETDOWN",
|
|
244
|
+
"ENETUNREACH",
|
|
245
|
+
"FailedToOpenSocket",
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
/** Narrow transient taxonomy; authentication, DNS, TLS and validation fail fast. */
|
|
249
|
+
export function isRetryableWaitError(error: unknown): boolean {
|
|
250
|
+
if (!(error instanceof CentrsError)) return false;
|
|
251
|
+
if (
|
|
252
|
+
[
|
|
253
|
+
"transport/connection-refused",
|
|
254
|
+
"transport/connection-closed",
|
|
255
|
+
"transport/timeout",
|
|
256
|
+
].includes(error.code)
|
|
257
|
+
)
|
|
258
|
+
return true;
|
|
259
|
+
// Both adapters retain the OS cause when a reset or an absent host maps to
|
|
260
|
+
// the generic network code. Retry those causes, never all network failures.
|
|
261
|
+
// A router rebooting on the local LAN fails ARP, and macOS then answers
|
|
262
|
+
// later connects with "host down": Bun fetch reports that `FailedToOpenSocket`.
|
|
263
|
+
if (error.code !== "transport/network") return false;
|
|
264
|
+
const seen = new Set<unknown>();
|
|
265
|
+
let cause: unknown = error.cause;
|
|
266
|
+
while (cause && typeof cause === "object" && !seen.has(cause)) {
|
|
267
|
+
seen.add(cause);
|
|
268
|
+
if ("code" in cause && TRANSIENT_CAUSES.has(String(cause.code)))
|
|
269
|
+
return true;
|
|
270
|
+
cause = "cause" in cause ? cause.cause : undefined;
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
276
|
+
if (signal.aborted || ms <= 0) return Promise.resolve();
|
|
277
|
+
return new Promise((resolve) => {
|
|
278
|
+
const done = () => {
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
signal.removeEventListener("abort", done);
|
|
281
|
+
resolve();
|
|
282
|
+
};
|
|
283
|
+
const timer = setTimeout(done, ms);
|
|
284
|
+
signal.addEventListener("abort", done, { once: true });
|
|
285
|
+
});
|
|
286
|
+
}
|