talon-agent 5.24.0 → 5.25.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/package.json +2 -1
- package/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/index.ts +1 -1
- package/src/core/agents/prompt.ts +29 -2
- package/src/core/agents/runner.ts +7 -1
- package/src/core/agents/types.ts +6 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +82 -0
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/agents/control.ts +9 -0
- package/src/core/engine/gateway-actions/agents/index.ts +4 -0
- package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +1 -1
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +5 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/index.ts +2 -1
- package/src/core/tools/ops/agents.ts +28 -0
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- package/src/core/background/triggers/pid.ts +0 -28
|
@@ -6,10 +6,6 @@
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
readBodyLimited,
|
|
11
|
-
ResponseTooLargeError,
|
|
12
|
-
} from "../../../../util/http-body.js";
|
|
13
9
|
import {
|
|
14
10
|
advertisedBinaryKind,
|
|
15
11
|
decodeText,
|
|
@@ -22,7 +18,9 @@ import {
|
|
|
22
18
|
import { dirs } from "../../../../util/paths.js";
|
|
23
19
|
import { getPoolConfig } from "../../backend-controller/index.js";
|
|
24
20
|
import type { SharedActionHandlers } from "../types.js";
|
|
25
|
-
import {
|
|
21
|
+
import { logDebug } from "../../../../util/log.js";
|
|
22
|
+
import { buildFetchLadder } from "../../../fetch/index.js";
|
|
23
|
+
import { BlockedUrlError } from "./guard.js";
|
|
26
24
|
|
|
27
25
|
const MAX_RESPONSE_MB = 50;
|
|
28
26
|
const MAX_RESPONSE_BYTES = MAX_RESPONSE_MB * 1024 * 1024;
|
|
@@ -69,55 +67,37 @@ function textResult(
|
|
|
69
67
|
return { ok: true, text: capText(text) };
|
|
70
68
|
}
|
|
71
69
|
|
|
70
|
+
/** Footer naming the rung that got the page, plus any caveat. */
|
|
71
|
+
function footer(via: string, note?: string): string {
|
|
72
|
+
return `\n\n[fetched via ${via}${note ? ` — ${note}` : ""}]`;
|
|
73
|
+
}
|
|
74
|
+
|
|
72
75
|
export const fetchUrlHandlers: SharedActionHandlers = {
|
|
73
76
|
fetch_url: async (body) => {
|
|
74
77
|
const url = String(body.url ?? "");
|
|
75
78
|
const invalid = urlError(url);
|
|
76
79
|
if (invalid) return { ok: false, error: invalid };
|
|
77
80
|
try {
|
|
81
|
+
// The ladder (core/fetch) climbs browser-TLS impersonation → SOCKS
|
|
82
|
+
// exits → plain fetch → browser → egress device until one answers
|
|
83
|
+
// with content, and stops early on a definitive answer (a 404).
|
|
78
84
|
// Local addresses are reachable by default; with
|
|
79
85
|
// `fetchUrl.allowPrivateNetworks: false` every hop is checked against
|
|
80
86
|
// private/loopback/link-local ranges (see guard.ts).
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
},
|
|
91
|
-
);
|
|
92
|
-
if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
|
|
93
|
-
const ct = resp.headers.get("content-type") ?? "";
|
|
94
|
-
|
|
95
|
-
// Reject oversized responses before downloading the body.
|
|
96
|
-
// The Content-Length header is advisory but saves bandwidth when present.
|
|
97
|
-
const contentLength = resp.headers.get("content-length");
|
|
98
|
-
if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES) {
|
|
99
|
-
return {
|
|
100
|
-
ok: false,
|
|
101
|
-
error: `File too large (${(Number(contentLength) / 1024 / 1024).toFixed(0)}MB, max ${MAX_RESPONSE_MB}MB)`,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
87
|
+
const ladder = buildFetchLadder(getPoolConfig(), {
|
|
88
|
+
maxBytes: MAX_RESPONSE_BYTES,
|
|
89
|
+
});
|
|
90
|
+
const result = await ladder.fetch(url);
|
|
91
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
92
|
+
logDebug("fetch", `fetch via ${result.via} (${new URL(url).host})`);
|
|
93
|
+
const via = footer(result.via, result.note);
|
|
94
|
+
const ct = result.headers.get("content-type") ?? "";
|
|
95
|
+
const buffer = result.body;
|
|
104
96
|
|
|
105
97
|
const mimeType = ct.split(";")[0].trim().toLowerCase();
|
|
106
|
-
let buffer: Buffer;
|
|
107
|
-
try {
|
|
108
|
-
buffer = await readBodyLimited(resp, MAX_RESPONSE_BYTES);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
if (err instanceof ResponseTooLargeError) {
|
|
111
|
-
return {
|
|
112
|
-
ok: false,
|
|
113
|
-
error: `Response too large (max ${MAX_RESPONSE_MB}MB)`,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
throw err;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
98
|
if (isTextContent(mimeType, buffer)) {
|
|
120
|
-
|
|
99
|
+
const text = textResult(mimeType, buffer, ct);
|
|
100
|
+
return { ...text, text: text.text + via };
|
|
121
101
|
}
|
|
122
102
|
|
|
123
103
|
if (buffer.length === 0)
|
|
@@ -148,7 +128,7 @@ export const fetchUrlHandlers: SharedActionHandlers = {
|
|
|
148
128
|
: (detected?.ext ?? ct.split("/")[1]?.split(";")[0] ?? "file");
|
|
149
129
|
return {
|
|
150
130
|
ok: true,
|
|
151
|
-
text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}")
|
|
131
|
+
text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").${via}`,
|
|
152
132
|
};
|
|
153
133
|
} catch (err) {
|
|
154
134
|
if (err instanceof BlockedUrlError) {
|
|
@@ -91,6 +91,9 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
91
91
|
body.name,
|
|
92
92
|
body.bridge_url,
|
|
93
93
|
),
|
|
94
|
+
// Companion pairing: the agent-tool twin of Telegram's `/mesh pair`.
|
|
95
|
+
make_companion_pair_link: async (body) =>
|
|
96
|
+
getMeshService().makeCompanionPairLinkText(body.label, body.bridge_url),
|
|
94
97
|
};
|
|
95
98
|
|
|
96
99
|
/**
|
|
@@ -124,4 +127,5 @@ export const chatFreeActions: ReadonlySet<string> = new Set([
|
|
|
124
127
|
"update_node",
|
|
125
128
|
"get_node_binary",
|
|
126
129
|
"make_node_install_link",
|
|
130
|
+
"make_companion_pair_link",
|
|
127
131
|
]);
|
|
@@ -18,10 +18,21 @@ import {
|
|
|
18
18
|
collectBackendUsage,
|
|
19
19
|
formatHeadroom,
|
|
20
20
|
leadWith,
|
|
21
|
+
type BackendHeadroom,
|
|
21
22
|
type BackendUsageSnapshot,
|
|
22
23
|
} from "../backend-router/index.js";
|
|
23
24
|
import type { SharedActionHandlers } from "./types.js";
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Headroom as agents see it, to 2dp. `null` for an unmeasured backend: the
|
|
28
|
+
* router scores those 0 so they are never preferred, but telling an agent
|
|
29
|
+
* "0" would read as "spent", which is not known either.
|
|
30
|
+
*/
|
|
31
|
+
function headroomFigure(entry: BackendHeadroom): number | null {
|
|
32
|
+
if (entry.source === "none" && !entry.unavailable) return null;
|
|
33
|
+
return Math.round(entry.headroom * 100) / 100;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
/** One `plan_usage` block: the headroom line, then any plan windows. */
|
|
26
37
|
function usageLines(entry: BackendUsageSnapshot): string[] {
|
|
27
38
|
const head = `- ${entry.label || entry.id}: ${formatHeadroom(entry.headroom)}`;
|
|
@@ -143,10 +154,13 @@ export const modelHandlers: SharedActionHandlers = {
|
|
|
143
154
|
id: entry.id,
|
|
144
155
|
label: entry.label,
|
|
145
156
|
current: entry.id === currentId,
|
|
146
|
-
headroom:
|
|
157
|
+
headroom: headroomFigure(entry.headroom),
|
|
147
158
|
source: entry.headroom.source,
|
|
148
159
|
limiting: entry.headroom.limiting ?? null,
|
|
149
160
|
stale: entry.headroom.stale ?? false,
|
|
161
|
+
...(entry.headroom.unavailable
|
|
162
|
+
? { unavailable: entry.headroom.unavailable }
|
|
163
|
+
: {}),
|
|
150
164
|
plan: entry.plan?.plan ?? null,
|
|
151
165
|
windows: entry.plan?.windows ?? [],
|
|
152
166
|
...(entry.note ? { note: entry.note } : {}),
|
|
@@ -170,10 +184,11 @@ export const modelHandlers: SharedActionHandlers = {
|
|
|
170
184
|
id: b.id,
|
|
171
185
|
label: b.label,
|
|
172
186
|
current: b.id === currentId,
|
|
173
|
-
headroom: entry
|
|
174
|
-
? Math.round(entry.headroom.headroom * 100) / 100
|
|
175
|
-
: null,
|
|
187
|
+
headroom: entry ? headroomFigure(entry.headroom) : null,
|
|
176
188
|
headroomSource: entry?.headroom.source ?? null,
|
|
189
|
+
...(entry?.headroom.unavailable
|
|
190
|
+
? { unavailable: entry.headroom.unavailable }
|
|
191
|
+
: {}),
|
|
177
192
|
};
|
|
178
193
|
});
|
|
179
194
|
const lines = backends.map((b) => {
|
|
@@ -320,16 +320,45 @@ export async function dispatchGatewayRoute(
|
|
|
320
320
|
|
|
321
321
|
const PORT_RETRIES = 5;
|
|
322
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Asked before walking past a busy port. Resolves a reason to refuse the
|
|
325
|
+
* fallback (the bind then fails with it), or `undefined` to keep walking.
|
|
326
|
+
*/
|
|
327
|
+
export type PortInUseCheck = (busyPort: number) => Promise<string | undefined>;
|
|
328
|
+
|
|
323
329
|
/**
|
|
324
330
|
* Bind to 127.0.0.1, walking up from `port` on EADDRINUSE (at most
|
|
325
331
|
* `PORT_RETRIES` times). Resolves with the port actually bound — `port`
|
|
326
332
|
* 0 asks the OS for a free one — and leaves a persistent error handler
|
|
327
333
|
* on the server, so a later server-level error is logged instead of
|
|
328
334
|
* crashing the process via an unhandled 'error' event.
|
|
335
|
+
*
|
|
336
|
+
* `onPortInUse` can veto the walk: the daemon uses it to fail outright when
|
|
337
|
+
* the busy port belongs to another Talon daemon, rather than settling on
|
|
338
|
+
* the next port and running as a second instance.
|
|
329
339
|
*/
|
|
330
|
-
export function listenWithRetry(
|
|
340
|
+
export function listenWithRetry(
|
|
341
|
+
server: Server,
|
|
342
|
+
port: number,
|
|
343
|
+
onPortInUse?: PortInUseCheck,
|
|
344
|
+
): Promise<number> {
|
|
331
345
|
return new Promise<number>((resolve, reject) => {
|
|
332
346
|
let attempt = 0;
|
|
347
|
+
const walkOn = async (
|
|
348
|
+
candidate: number,
|
|
349
|
+
err: NodeJS.ErrnoException,
|
|
350
|
+
): Promise<void> => {
|
|
351
|
+
const refusal = onPortInUse
|
|
352
|
+
? await onPortInUse(candidate).catch(() => undefined)
|
|
353
|
+
: undefined;
|
|
354
|
+
if (refusal) {
|
|
355
|
+
reject(
|
|
356
|
+
Object.assign(new Error(refusal), { code: err.code, cause: err }),
|
|
357
|
+
);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
tryPort(candidate + 1);
|
|
361
|
+
};
|
|
333
362
|
const tryPort = (candidate: number): void => {
|
|
334
363
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
335
364
|
if (err.code === "EADDRINUSE" && attempt < PORT_RETRIES) {
|
|
@@ -339,7 +368,7 @@ export function listenWithRetry(server: Server, port: number): Promise<number> {
|
|
|
339
368
|
// registered; drop it or every stale callback fires when a later
|
|
340
369
|
// port finally binds.
|
|
341
370
|
server.removeAllListeners("listening");
|
|
342
|
-
|
|
371
|
+
void walkOn(candidate, err);
|
|
343
372
|
} else {
|
|
344
373
|
reject(err);
|
|
345
374
|
}
|
|
@@ -30,6 +30,7 @@ import { getHubSessionCount } from "../mcp-hub/index.js";
|
|
|
30
30
|
import {
|
|
31
31
|
dispatchGatewayRoute,
|
|
32
32
|
listenWithRetry,
|
|
33
|
+
type PortInUseCheck,
|
|
33
34
|
type GatewayRouteHost,
|
|
34
35
|
} from "./gateway-routes.js";
|
|
35
36
|
import { gatewayToken } from "./gateway-auth.js";
|
|
@@ -471,6 +472,15 @@ export class Gateway {
|
|
|
471
472
|
* A failed bind clears the in-flight state, so a later `start()` retries;
|
|
472
473
|
* so does `stop()`, so start-after-stop binds afresh.
|
|
473
474
|
*/
|
|
475
|
+
/**
|
|
476
|
+
* Veto for the EADDRINUSE port walk (see `listenWithRetry`). The daemon
|
|
477
|
+
* installs one that refuses when the busy port is another daemon.
|
|
478
|
+
*/
|
|
479
|
+
setPortInUseCheck(check: PortInUseCheck | undefined): void {
|
|
480
|
+
this.portInUseCheck = check;
|
|
481
|
+
}
|
|
482
|
+
private portInUseCheck: PortInUseCheck | undefined;
|
|
483
|
+
|
|
474
484
|
async start(port = 19876): Promise<number> {
|
|
475
485
|
if (this.server) return this.port;
|
|
476
486
|
if (this.starting) return this.starting;
|
|
@@ -509,7 +519,7 @@ export class Gateway {
|
|
|
509
519
|
const httpServer = createServer(
|
|
510
520
|
(req, res) => void dispatchGatewayRoute(req, res, host),
|
|
511
521
|
);
|
|
512
|
-
const bound = await listenWithRetry(httpServer, port);
|
|
522
|
+
const bound = await listenWithRetry(httpServer, port, this.portInUseCheck);
|
|
513
523
|
this.server = httpServer;
|
|
514
524
|
this.port = bound;
|
|
515
525
|
log("gateway", `Action gateway on :${this.port}`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response classification for the fetch ladder: is this content, a bot
|
|
3
|
+
* wall worth climbing past, or a definitive answer (a wrong URL) that no
|
|
4
|
+
* amount of climbing will change?
|
|
5
|
+
*
|
|
6
|
+
* Getting "blocked" vs "not found" right is the whole point: climbing on a
|
|
7
|
+
* 404 hammers a site through every exit for nothing and then reports a
|
|
8
|
+
* block that never happened; stopping on a 403 challenge gives up on a
|
|
9
|
+
* page that the next rung would have fetched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Verdict } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/** Statuses bot walls answer with. 999 is LinkedIn's. */
|
|
15
|
+
const BLOCK_STATUSES = new Set([403, 429, 503, 999]);
|
|
16
|
+
const NOT_FOUND_STATUSES = new Set([404, 410]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Challenge pages are small; a real article that merely mentions
|
|
20
|
+
* "captcha" is not. Only bodies under this size are scanned.
|
|
21
|
+
*/
|
|
22
|
+
const CHALLENGE_SCAN_BYTES = 32 * 1024;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Markers of interstitial challenge pages (Cloudflare, DataDome, Akamai,
|
|
26
|
+
* Imperva/Incapsula, PerimeterX). Deliberately no bare "captcha" or
|
|
27
|
+
* reCAPTCHA marker: plenty of ordinary pages embed a form captcha. A 2xx
|
|
28
|
+
* page flagged here is kept as a fallback by the ladder, so a false
|
|
29
|
+
* positive costs extra rungs, never the content.
|
|
30
|
+
*/
|
|
31
|
+
const CHALLENGE_MARKERS: readonly RegExp[] = [
|
|
32
|
+
// Not "challenge-platform": Cloudflare injects that script path into
|
|
33
|
+
// ordinary pages too. Same for the DataDome/PerimeterX tag scripts —
|
|
34
|
+
// only their challenge iframes/widgets count.
|
|
35
|
+
/cf-chl-|cf_chl_opt|<title>Just a moment\.\.\.<\/title>/i,
|
|
36
|
+
/Attention Required! \| Cloudflare/i,
|
|
37
|
+
/captcha-delivery\.com/i,
|
|
38
|
+
/_Incapsula_Resource|Incapsula incident/i,
|
|
39
|
+
/px-captcha/i,
|
|
40
|
+
/Pardon Our Interruption/i,
|
|
41
|
+
/<title>Access Denied<\/title>[\s\S]*Reference #/i,
|
|
42
|
+
/verify (that )?you are (a )?human|are you a robot/i,
|
|
43
|
+
/challenges\.cloudflare\.com\/turnstile/i,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** All markers as one regex source (for the browser driver subprocess). */
|
|
47
|
+
export const CHALLENGE_PATTERN = CHALLENGE_MARKERS.map(
|
|
48
|
+
(re) => `(?:${re.source})`,
|
|
49
|
+
).join("|");
|
|
50
|
+
|
|
51
|
+
/** True when a small body looks like a bot-check interstitial. */
|
|
52
|
+
export function looksLikeChallenge(body: Buffer | string): boolean {
|
|
53
|
+
const size = typeof body === "string" ? body.length : body.byteLength;
|
|
54
|
+
if (size === 0 || size > CHALLENGE_SCAN_BYTES) return false;
|
|
55
|
+
const text = typeof body === "string" ? body : body.toString("latin1");
|
|
56
|
+
return CHALLENGE_MARKERS.some((re) => re.test(text));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Judge one HTTP answer. Network failures never reach here. */
|
|
60
|
+
export function classifyResponse(status: number, body: Buffer): Verdict {
|
|
61
|
+
if (BLOCK_STATUSES.has(status)) return "blocked";
|
|
62
|
+
if (looksLikeChallenge(body)) return "blocked";
|
|
63
|
+
if (status >= 200 && status < 300) return "ok";
|
|
64
|
+
if (NOT_FOUND_STATUSES.has(status)) return "not-found";
|
|
65
|
+
return "http-error";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Verdicts after which trying another rung is pointless. */
|
|
69
|
+
export function isDefinitive(verdict: Verdict): boolean {
|
|
70
|
+
return (
|
|
71
|
+
verdict === "ok" || verdict === "not-found" || verdict === "http-error"
|
|
72
|
+
);
|
|
73
|
+
}
|