model-compare-opencode-plugin 0.1.3 → 0.4.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/README.md +39 -7
- package/dist/tui.js +123 -10
- package/package.json +1 -1
- package/src/config.ts +5 -2
- package/src/core/catalog.ts +59 -12
- package/src/core/steering.ts +145 -0
- package/src/core/types.ts +28 -1
- package/src/index.ts +463 -15
- package/src/opencode/commands.ts +64 -3
- package/src/opencode/storage.ts +6 -1
- package/src/rpc.ts +45 -3
- package/src/sources/drops.ts +37 -0
- package/src/sources/index.ts +18 -6
- package/src/sources/local.ts +7 -3
- package/src/sources/remote.ts +68 -10
- package/src/tui.tsx +149 -9
- package/vendor/UPSTREAM.json +3 -3
- package/vendor/model_compare.py +1831 -106
package/src/opencode/commands.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// not upstream's `generated_at`.
|
|
9
9
|
|
|
10
10
|
import { effectiveWeights, overallScore } from "../core/rank"
|
|
11
|
-
import type { Catalog, ModelEntry, Priority, Sourced, Weights } from "../core/types"
|
|
11
|
+
import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced, Weights } from "../core/types"
|
|
12
12
|
import type { AliasTarget } from "./aliases"
|
|
13
13
|
|
|
14
14
|
const PRIORITIES: Priority[] = ["balanced", "price", "quality"]
|
|
@@ -64,13 +64,66 @@ export function sanitizeCatalogText(text: string): string {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
// What the report knows beyond the ranking: the pick each alias currently
|
|
67
|
-
// serves (a pin can differ from rank 1 — minLead hysteresis)
|
|
68
|
-
//
|
|
67
|
+
// serves (a pin can differ from rank 1 — minLead hysteresis), which priorities
|
|
68
|
+
// rank under a weights override, the aliases' enabled flags (the steering
|
|
69
|
+
// note's "every enabled alias" scope), and the session's last-endpoint
|
|
70
|
+
// capture (the footer's last-response line — absent when nothing was
|
|
71
|
+
// captured for the session the report belongs to).
|
|
69
72
|
export type ReportContext = {
|
|
70
73
|
picks?: Partial<Record<Priority, AliasTarget>>
|
|
71
74
|
overrides?: Partial<Record<Priority, Partial<Weights>>>
|
|
75
|
+
enabled?: Partial<Record<Priority, boolean>>
|
|
76
|
+
capture?: { endpoint: string; model: string; downgrade: boolean } | null
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
// The report footer's steering note (design §Steering visibility): the union
|
|
80
|
+
// of the current picks' steering blocks' `quantizations` lists, resolved
|
|
81
|
+
// through the catalog's ModelEntry map, across the three ENABLED aliases.
|
|
82
|
+
// It renders only when EVERY enabled alias's current pick carries
|
|
83
|
+
// quantizations data (a pick missing from the catalog, or a steering block
|
|
84
|
+
// without the list, withholds the whole note — no claim on partial data);
|
|
85
|
+
// a disabled alias's pick is excluded from both the union and the check, and
|
|
86
|
+
// an empty quantizations list contributes nothing without withholding. No
|
|
87
|
+
// enabled aliases → no note. Steering shipped with the Q2 verdict PASS, so
|
|
88
|
+
// the note renders purely per this data rule. (That data-only rule holds
|
|
89
|
+
// because steering injection IS wired — Q2 PASS; in the hypothetical Q2 FAIL
|
|
90
|
+
// world the note's suppression would be a code change, not a data condition.)
|
|
91
|
+
export function steeringNote(
|
|
92
|
+
doc: Catalog,
|
|
93
|
+
picks: Partial<Record<Priority, AliasTarget>> = {},
|
|
94
|
+
enabled: Partial<Record<Priority, boolean>> = {},
|
|
95
|
+
): string | null {
|
|
96
|
+
const byId = new Map(doc.models.map((model) => [model.id, model]))
|
|
97
|
+
const union: string[] = []
|
|
98
|
+
for (const p of PRIORITIES) {
|
|
99
|
+
if (enabled[p] !== true) continue
|
|
100
|
+
const pick = picks[p]
|
|
101
|
+
const steering =
|
|
102
|
+
pick === undefined || pick.id === null ? undefined : byId.get(pick.id)?.steering
|
|
103
|
+
const quants = steering?.quantizations
|
|
104
|
+
if (quants === undefined) return null
|
|
105
|
+
for (const quant of quants) if (!union.includes(quant)) union.push(quant)
|
|
106
|
+
}
|
|
107
|
+
return union.length === 0 ? null : `routing steered to ${union.join(", ")} endpoints (aliases only)`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The report footer's last-response line (design §Steering visibility, the
|
|
111
|
+
// user's option C): `last response: <model> @ <endpoint>` — the captured
|
|
112
|
+
// response's concrete model id and the served endpoint verbatim. An empty
|
|
113
|
+
// model (a response whose provider carried no model id) omits the model part
|
|
114
|
+
// rather than rendering an empty `@` slot. No capture → null: the line is
|
|
115
|
+
// omitted entirely, never a placeholder.
|
|
116
|
+
export function lastResponseLine(
|
|
117
|
+
capture: Pick<EndpointCapture, "endpoint" | "model" | "downgrade"> | null | undefined,
|
|
118
|
+
): string | null {
|
|
119
|
+
if (capture === null || capture === undefined) return null
|
|
120
|
+
const endpoint = sanitizeCatalogText(capture.endpoint)
|
|
121
|
+
return capture.model === ""
|
|
122
|
+
? `last response: ${endpoint}`
|
|
123
|
+
: `last response: ${sanitizeCatalogText(capture.model)} @ ${endpoint}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
74
127
|
// Whether a priority's weights override actually took effect, shared by the
|
|
75
128
|
// two report builders (formatReport, reportEntries) so the "(custom weights)"
|
|
76
129
|
// label and the recomputed scores can never disagree: the effective weights
|
|
@@ -138,6 +191,14 @@ export function formatReport(
|
|
|
138
191
|
}
|
|
139
192
|
}
|
|
140
193
|
}
|
|
194
|
+
// The footer (design §Steering visibility): the steering note ABOVE the
|
|
195
|
+
// last-response line, both omitted when their data is absent. The
|
|
196
|
+
// transcript's text report and the TUI dialog's footer agree — one
|
|
197
|
+
// derivation, two surfaces.
|
|
198
|
+
const note = steeringNote(sourced.doc, context.picks, context.enabled)
|
|
199
|
+
if (note !== null) lines.push(note)
|
|
200
|
+
const lastResponse = lastResponseLine(context.capture)
|
|
201
|
+
if (lastResponse !== null) lines.push(lastResponse)
|
|
141
202
|
return lines.join("\n")
|
|
142
203
|
}
|
|
143
204
|
|
package/src/opencode/storage.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type Storage = {
|
|
|
16
16
|
// Reads a versioned record. Returns null — never throws — when the key is
|
|
17
17
|
// missing, the value is not a JSON object, or `v` is missing or not 1 (§7.2:
|
|
18
18
|
// corrupt or unknown-version entries are discarded and treated as absent).
|
|
19
|
+
// The doc-cache layer adds its own field checks (readCachedDoc below).
|
|
19
20
|
export async function readRecord<T extends { v: number }>(
|
|
20
21
|
storage: Storage,
|
|
21
22
|
key: string,
|
|
@@ -60,7 +61,11 @@ export async function readCachedDoc(storage: Storage, identity: string): Promise
|
|
|
60
61
|
const record = await readRecord<DocCacheRecord>(storage, docCacheKey(identity))
|
|
61
62
|
if (record === null) return null
|
|
62
63
|
if (record.source !== identity) return null
|
|
63
|
-
|
|
64
|
+
// acquiredAt must be a FINITE number, mirroring isValidPin (src/index.ts):
|
|
65
|
+
// JSON.parse("1e999") parses to Infinity, and now() - Infinity < ttlMs is
|
|
66
|
+
// always true — an unchecked Infinity would be served as perpetually fresh
|
|
67
|
+
// and never re-fetched under this identity (final-review BUG-04).
|
|
68
|
+
if (typeof record.acquiredAt !== "number" || !Number.isFinite(record.acquiredAt)) return null
|
|
64
69
|
const validated = validateStoredCatalog(record.doc)
|
|
65
70
|
if (!validated.ok) {
|
|
66
71
|
console.warn("[model-compare]", `discarding the cached catalog: ${validated.reason}`)
|
package/src/rpc.ts
CHANGED
|
@@ -8,16 +8,41 @@
|
|
|
8
8
|
// surfaces at the session's next turn.
|
|
9
9
|
import { Rpc } from "@opencode/plugin/rpc"
|
|
10
10
|
|
|
11
|
+
// The capture record the report payload carries (the footer's last-response
|
|
12
|
+
// line and the alt+e toast's data): the served endpoint verbatim, the
|
|
13
|
+
// response's concrete model id (empty when the response had none), and the
|
|
14
|
+
// downgrade verdict. Null when nothing was captured for the session.
|
|
15
|
+
const captureSchema = {
|
|
16
|
+
type: ["object", "null"],
|
|
17
|
+
properties: {
|
|
18
|
+
endpoint: { type: "string" },
|
|
19
|
+
model: { type: "string" },
|
|
20
|
+
downgrade: { type: "boolean" },
|
|
21
|
+
},
|
|
22
|
+
required: ["endpoint", "model", "downgrade"],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
11
26
|
export const ModelCompare = Rpc.define({
|
|
12
27
|
id: "model-compare",
|
|
13
28
|
methods: {
|
|
14
29
|
report: {
|
|
15
|
-
|
|
30
|
+
// The session whose capture the footer fields report: the TUI passes
|
|
31
|
+
// the CURRENT session's id (null on the opening screen — no capture
|
|
32
|
+
// can belong to no session). Optional for the opening screen's
|
|
33
|
+
// existing `{}` calls.
|
|
34
|
+
input: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { sessionID: { type: ["string", "null"] } },
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
16
39
|
output: {
|
|
17
40
|
type: "object",
|
|
18
41
|
properties: {
|
|
19
42
|
text: { type: "string" },
|
|
20
43
|
age: { type: ["string", "null"] },
|
|
44
|
+
capture: captureSchema,
|
|
45
|
+
steeringNote: { type: ["string", "null"] },
|
|
21
46
|
entries: {
|
|
22
47
|
type: "array",
|
|
23
48
|
items: {
|
|
@@ -38,7 +63,7 @@ export const ModelCompare = Rpc.define({
|
|
|
38
63
|
},
|
|
39
64
|
},
|
|
40
65
|
},
|
|
41
|
-
required: ["text", "age", "entries"],
|
|
66
|
+
required: ["text", "age", "capture", "steeringNote", "entries"],
|
|
42
67
|
additionalProperties: false,
|
|
43
68
|
},
|
|
44
69
|
},
|
|
@@ -50,6 +75,8 @@ export const ModelCompare = Rpc.define({
|
|
|
50
75
|
properties: {
|
|
51
76
|
sessionID: { type: "string" },
|
|
52
77
|
age: { type: ["string", "null"] },
|
|
78
|
+
capture: captureSchema,
|
|
79
|
+
steeringNote: { type: ["string", "null"] },
|
|
53
80
|
entries: {
|
|
54
81
|
type: "array",
|
|
55
82
|
items: {
|
|
@@ -70,7 +97,22 @@ export const ModelCompare = Rpc.define({
|
|
|
70
97
|
},
|
|
71
98
|
},
|
|
72
99
|
},
|
|
73
|
-
required: ["sessionID", "age", "entries"],
|
|
100
|
+
required: ["sessionID", "age", "capture", "steeringNote", "entries"],
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
// The Q2 fallback's cannot-steer notification (the runtime hook emits it
|
|
105
|
+
// once per session when a steerable alias request finds no steering
|
|
106
|
+
// data): the TUI's toast listens for `rpc.model-compare.cannotSteer`.
|
|
107
|
+
// Declared here like every other event — the host fails emits of
|
|
108
|
+
// undeclared names with "Unknown RPC event".
|
|
109
|
+
cannotSteer: {
|
|
110
|
+
schema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
sessionID: { type: "string" },
|
|
114
|
+
},
|
|
115
|
+
required: ["sessionID"],
|
|
74
116
|
additionalProperties: false,
|
|
75
117
|
},
|
|
76
118
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/sources/drops.ts
|
|
2
|
+
// Acquisition diagnostics (final-review BUG-02): since upstream 0.4.0 the
|
|
3
|
+
// vendored script can drop models at its endpoint-pricing stage when their
|
|
4
|
+
// endpoint fetch fails (retries exhausted), reporting it in the raw
|
|
5
|
+
// document's `pool.dropped` map under `endpoint_fetch_failed`. The catalog
|
|
6
|
+
// projection discards that field, so without this check a cold-cache run on
|
|
7
|
+
// a degraded network silently serves a smaller pool — and the smaller pool
|
|
8
|
+
// can change which model a pin switches to. The warning travels in
|
|
9
|
+
// Acquired.warnings, which the refresh tick logs.
|
|
10
|
+
//
|
|
11
|
+
// Scope (orchestrator ruling on the fix-wave-2 brief): `pool.dropped` also
|
|
12
|
+
// carries the by-design funnel drops (a healthy 468-listed run reports
|
|
13
|
+
// ~397 models dropped for context/pricing/batch/ZDR/... to reach its 71
|
|
14
|
+
// candidates). Only `endpoint_fetch_failed` signals a degraded-network
|
|
15
|
+
// shrink; warning on the funnel would fire on every healthy run. Limitation
|
|
16
|
+
// (README, Troubleshooting): under `opencode serve` plugin console output
|
|
17
|
+
// reaches no log — this is diagnostics for contexts that see plugin output,
|
|
18
|
+
// not a user-visible alarm.
|
|
19
|
+
|
|
20
|
+
// Reads the raw upstream document's `pool.dropped` map and returns one
|
|
21
|
+
// warning string when models were dropped at the endpoint stage. Defensive
|
|
22
|
+
// at every step: a document without a well-formed pool.dropped emits nothing
|
|
23
|
+
// (the warning is best-effort diagnostics, never a validation gate).
|
|
24
|
+
export function dropWarningsFromRaw(raw: unknown): string[] {
|
|
25
|
+
if (typeof raw !== "object" || raw === null) return []
|
|
26
|
+
const pool = (raw as { pool?: unknown }).pool
|
|
27
|
+
if (typeof pool !== "object" || pool === null) return []
|
|
28
|
+
const dropped = (pool as { dropped?: unknown }).dropped
|
|
29
|
+
if (typeof dropped !== "object" || dropped === null) return []
|
|
30
|
+
const endpointFailed = (dropped as Record<string, unknown>).endpoint_fetch_failed
|
|
31
|
+
if (typeof endpointFailed !== "number" || !Number.isFinite(endpointFailed) || endpointFailed <= 0) {
|
|
32
|
+
return []
|
|
33
|
+
}
|
|
34
|
+
return [
|
|
35
|
+
`catalog pool shrunk: ${endpointFailed} model(s) dropped at the endpoint stage (endpoint_fetch_failed: ${endpointFailed})`,
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/sources/index.ts
CHANGED
|
@@ -57,19 +57,28 @@ export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired>
|
|
|
57
57
|
// fromCache flag is lifted to the result; sourced itself stays a plain
|
|
58
58
|
// Sourced (§5).
|
|
59
59
|
if (cfg.source === "remote") {
|
|
60
|
-
const { doc, acquiredAt, fromCache } = await doFetch(
|
|
60
|
+
const { doc, acquiredAt, fromCache, warnings: remoteWarnings } = await doFetch(
|
|
61
|
+
cfg.catalogURL,
|
|
62
|
+
deps.storage,
|
|
63
|
+
)
|
|
61
64
|
return {
|
|
62
65
|
sourced: { doc, acquiredAt },
|
|
63
66
|
source: "remote",
|
|
64
67
|
identity: remoteIdentity(cfg.catalogURL),
|
|
65
68
|
fromCache,
|
|
66
|
-
warnings: [],
|
|
69
|
+
warnings: remoteWarnings ?? [],
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
if (cfg.source === "local") {
|
|
71
74
|
const sourced = await runLocal(scriptPath)
|
|
72
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
sourced,
|
|
77
|
+
source: "local",
|
|
78
|
+
identity: localIdentity(cfg.scriptPath),
|
|
79
|
+
fromCache: false,
|
|
80
|
+
warnings: sourced.warnings ?? [],
|
|
81
|
+
}
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
// Auto: probe first; a usable python gets one local attempt. Either
|
|
@@ -84,7 +93,7 @@ export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired>
|
|
|
84
93
|
source: "local",
|
|
85
94
|
identity: localIdentity(cfg.scriptPath),
|
|
86
95
|
fromCache: false,
|
|
87
|
-
warnings: [],
|
|
96
|
+
warnings: sourced.warnings ?? [],
|
|
88
97
|
}
|
|
89
98
|
} catch (error) {
|
|
90
99
|
const cause = asReason(error).replace(LOCAL_PREFIX, "")
|
|
@@ -94,13 +103,16 @@ export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired>
|
|
|
94
103
|
warnings.push(`${probed.reason}; using remote catalog`)
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
const { doc, acquiredAt, fromCache } = await doFetch(
|
|
106
|
+
const { doc, acquiredAt, fromCache, warnings: remoteWarnings } = await doFetch(
|
|
107
|
+
cfg.catalogURL,
|
|
108
|
+
deps.storage,
|
|
109
|
+
)
|
|
98
110
|
return {
|
|
99
111
|
sourced: { doc, acquiredAt },
|
|
100
112
|
source: "remote",
|
|
101
113
|
identity: remoteIdentity(cfg.catalogURL),
|
|
102
114
|
fromCache,
|
|
103
|
-
warnings,
|
|
115
|
+
warnings: [...warnings, ...(remoteWarnings ?? [])],
|
|
104
116
|
}
|
|
105
117
|
}
|
|
106
118
|
|
package/src/sources/local.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { execFile as execFileCallback } from "node:child_process"
|
|
|
10
10
|
import { promisify } from "node:util"
|
|
11
11
|
import { validateCatalog } from "../core/catalog"
|
|
12
12
|
import type { Sourced } from "../core/types"
|
|
13
|
+
import { dropWarningsFromRaw } from "./drops"
|
|
13
14
|
|
|
14
15
|
const execFile = promisify(execFileCallback)
|
|
15
16
|
|
|
@@ -120,11 +121,14 @@ export async function probePython(
|
|
|
120
121
|
|
|
121
122
|
// Run the script (an explicit path — resolving the vendored default is the
|
|
122
123
|
// caller's job), validate its stdout, and stamp the plugin's own acquisition
|
|
123
|
-
// time on the document (design §5, §9).
|
|
124
|
+
// time on the document (design §5, §9). The raw document's pool.dropped map
|
|
125
|
+
// (models the vendored script dropped at its endpoint stage) travels as
|
|
126
|
+
// acquisition warnings — the projection discards it, so this is the only
|
|
127
|
+
// place the shrink can be observed.
|
|
124
128
|
export async function runLocalScript(
|
|
125
129
|
scriptPath: string,
|
|
126
130
|
timeoutMs = 60_000,
|
|
127
|
-
): Promise<Sourced> {
|
|
131
|
+
): Promise<Sourced & { warnings?: string[] }> {
|
|
128
132
|
let stdout: string
|
|
129
133
|
try {
|
|
130
134
|
const result = await execFile("python3", [scriptPath, "--catalog"], {
|
|
@@ -171,5 +175,5 @@ export async function runLocalScript(
|
|
|
171
175
|
if (!validated.ok) {
|
|
172
176
|
throw new Error(`local source failed: invalid catalog document: ${validated.reason}`)
|
|
173
177
|
}
|
|
174
|
-
return { doc: validated.catalog, acquiredAt: Date.now() }
|
|
178
|
+
return { doc: validated.catalog, acquiredAt: Date.now(), warnings: dropWarningsFromRaw(raw) }
|
|
175
179
|
}
|
package/src/sources/remote.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
writeCachedDoc,
|
|
15
15
|
type Storage,
|
|
16
16
|
} from "../opencode/storage"
|
|
17
|
+
import { dropWarningsFromRaw } from "./drops"
|
|
17
18
|
|
|
18
19
|
// The cache is revalidated after six hours (design §8).
|
|
19
20
|
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000
|
|
@@ -83,18 +84,69 @@ export type RemoteDeps = {
|
|
|
83
84
|
// HTTP status, the body parse, and the document each get their own reason).
|
|
84
85
|
// Network errors surface with their own message; non-OK statuses name the
|
|
85
86
|
// status code.
|
|
87
|
+
//
|
|
88
|
+
// Redirects are followed MANUALLY (Ruling 9): a redirect target is not
|
|
89
|
+
// re-checked by the config's https-only catalogURL rule, so a 301 to a
|
|
90
|
+
// plaintext http URL would otherwise deliver the catalog unencrypted. Every
|
|
91
|
+
// hop's resolved target must be https — the first non-https target fails the
|
|
92
|
+
// attempt BEFORE that request is made — and one signal bounds the whole
|
|
93
|
+
// chain.
|
|
94
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
95
|
+
const MAX_REDIRECT_HOPS = 5
|
|
96
|
+
|
|
86
97
|
async function fetchAndValidate(
|
|
87
98
|
url: string,
|
|
88
99
|
doFetch: typeof fetch,
|
|
89
100
|
now: () => number,
|
|
90
101
|
timeoutMs: number,
|
|
91
|
-
): Promise<{ ok: true; sourced: Sourced } | { ok: false; reason: string }> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
): Promise<{ ok: true; sourced: Sourced & { warnings: string[] } } | { ok: false; reason: string }> {
|
|
103
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
104
|
+
let current = url
|
|
105
|
+
for (let hops = 0; ; hops++) {
|
|
106
|
+
let response: Response
|
|
107
|
+
try {
|
|
108
|
+
response = await doFetch(current, { signal, redirect: "manual" })
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
111
|
+
}
|
|
112
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
113
|
+
await response.body?.cancel().catch(() => {})
|
|
114
|
+
if (hops >= MAX_REDIRECT_HOPS) {
|
|
115
|
+
return { ok: false, reason: `too many redirects (more than ${MAX_REDIRECT_HOPS} hops)` }
|
|
116
|
+
}
|
|
117
|
+
const location = response.headers?.get("location")
|
|
118
|
+
if (location === null || location.trim() === "") {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: `redirect response ${response.status} without a Location header`,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let target: URL
|
|
125
|
+
try {
|
|
126
|
+
target = new URL(location, current)
|
|
127
|
+
} catch {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: `redirect response ${response.status} with an unparseable Location header`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (target.protocol !== "https:") {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: `refusing redirect that leaves https (${target.href})`,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
current = target.href
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
return await acceptResponse(response, now)
|
|
97
143
|
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function acceptResponse(
|
|
147
|
+
response: Response,
|
|
148
|
+
now: () => number,
|
|
149
|
+
): Promise<{ ok: true; sourced: Sourced & { warnings: string[] } } | { ok: false; reason: string }> {
|
|
98
150
|
if (!response.ok) {
|
|
99
151
|
return { ok: false, reason: `HTTP status ${response.status}` }
|
|
100
152
|
}
|
|
@@ -121,7 +173,10 @@ async function fetchAndValidate(
|
|
|
121
173
|
if (!validated.ok) {
|
|
122
174
|
return { ok: false, reason: `invalid catalog document: ${validated.reason}` }
|
|
123
175
|
}
|
|
124
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
sourced: { doc: validated.catalog, acquiredAt: now(), warnings: dropWarningsFromRaw(raw) },
|
|
179
|
+
}
|
|
125
180
|
}
|
|
126
181
|
|
|
127
182
|
// Fetch the remote catalog with a persisted six-hour cache (design §5, §8):
|
|
@@ -133,7 +188,7 @@ export async function fetchRemote(
|
|
|
133
188
|
url: string,
|
|
134
189
|
storage: Storage,
|
|
135
190
|
deps: RemoteDeps = {},
|
|
136
|
-
): Promise<Sourced & { fromCache: boolean }> {
|
|
191
|
+
): Promise<Sourced & { fromCache: boolean; warnings?: string[] }> {
|
|
137
192
|
const doFetch = deps.fetch ?? globalThis.fetch
|
|
138
193
|
const now = deps.now ?? Date.now
|
|
139
194
|
const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS
|
|
@@ -141,14 +196,17 @@ export async function fetchRemote(
|
|
|
141
196
|
const identity = remoteIdentity(url)
|
|
142
197
|
const cached = await readCachedDoc(storage, identity)
|
|
143
198
|
if (cached !== null && now() - cached.acquiredAt < ttlMs) {
|
|
199
|
+
// A TTL-fresh cache hit carries no warnings: the cached record is the
|
|
200
|
+
// projected catalog (no raw pool), and re-warning on every cache read
|
|
201
|
+
// would turn one shrink into an alarm on each 30-minute tick.
|
|
144
202
|
return { ...cached, fromCache: true }
|
|
145
203
|
}
|
|
146
204
|
|
|
147
205
|
const attempt = await fetchAndValidate(url, doFetch, now, deps.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS)
|
|
148
206
|
if (attempt.ok) {
|
|
149
|
-
const { doc, acquiredAt } = attempt.sourced
|
|
207
|
+
const { doc, acquiredAt, warnings } = attempt.sourced
|
|
150
208
|
await writeCachedDoc(storage, identity, { doc, acquiredAt })
|
|
151
|
-
return { doc, acquiredAt, fromCache: false }
|
|
209
|
+
return { doc, acquiredAt, warnings, fromCache: false }
|
|
152
210
|
}
|
|
153
211
|
if (cached !== null) return { ...cached, fromCache: true }
|
|
154
212
|
throw new Error(`remote source failed: ${attempt.reason}`)
|