@patronage/software-factory 1.0.0-alpha.34 → 1.0.0-alpha.36
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 +2 -0
- package/dist/{chunk-pbuEa-1d.js → chunk-DrSxFLj_.js} +1 -0
- package/dist/index.d.ts +573 -185
- package/dist/index.js +4594 -2530
- package/dist/oxlint-jev/bridge-B4Sx95ZO.js +1007 -0
- package/dist/oxlint-jev/index.d.ts +6 -0
- package/dist/oxlint-jev/index.js +581 -0
- package/dist/oxlint-jev/worker.d.ts +1 -0
- package/dist/oxlint-jev/worker.js +49 -0
- package/dist/schemas.d.ts +177 -177
- package/package.json +8 -2
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { noul } from "@typesafe-ai/sdk";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
//#region src/jev/schema.ts
|
|
11
|
+
const noulAnswerSchema = z.object({
|
|
12
|
+
noul: z.number().min(0).max(1),
|
|
13
|
+
type: z.literal("noul")
|
|
14
|
+
});
|
|
15
|
+
const choiceAnswerSchema = z.object({
|
|
16
|
+
choice: z.string(),
|
|
17
|
+
confidence: z.number(),
|
|
18
|
+
probabilities: z.record(z.string(), z.number()),
|
|
19
|
+
type: z.literal("choice")
|
|
20
|
+
});
|
|
21
|
+
const scoreAnswerSchema = z.object({
|
|
22
|
+
confidence: z.number(),
|
|
23
|
+
probabilities: z.record(z.string(), z.number()),
|
|
24
|
+
score: z.number(),
|
|
25
|
+
type: z.literal("score")
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* One answer, discriminated exactly as Jev discriminates it.
|
|
29
|
+
*
|
|
30
|
+
* Every member is a *stripping* object: an unknown key inside one answer is
|
|
31
|
+
* dropped, not an error. Erroring would let a single surprising field sink a
|
|
32
|
+
* whole response, and keeping it would put an upstream-named field into a
|
|
33
|
+
* record. Dropping is the only behavior that is both safe and non-destructive.
|
|
34
|
+
*/
|
|
35
|
+
const jevAnswerSchema = z.discriminatedUnion("type", [
|
|
36
|
+
noulAnswerSchema,
|
|
37
|
+
choiceAnswerSchema,
|
|
38
|
+
scoreAnswerSchema
|
|
39
|
+
]);
|
|
40
|
+
/** Token counters, copied field by field. Never a passthrough object. */
|
|
41
|
+
const usageSchema = z.object({
|
|
42
|
+
input_tokens: z.number().optional(),
|
|
43
|
+
output_tokens: z.number().optional()
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Jev's response body. Unknown keys are stripped at every level: nothing
|
|
47
|
+
* upstream names may end up on a record simply because it was present.
|
|
48
|
+
*/
|
|
49
|
+
const jevResponseSchema = z.object({
|
|
50
|
+
answers: z.record(z.string(), jevAnswerSchema),
|
|
51
|
+
model: z.string().min(1),
|
|
52
|
+
usage: usageSchema.optional()
|
|
53
|
+
});
|
|
54
|
+
/** A model id is a short token. Anything longer or stranger is prose. */
|
|
55
|
+
const MODEL_ID_SHAPE = /^[A-Za-z0-9][\w.-]{0,63}$/u;
|
|
56
|
+
/** What a report says when HQ names its model in something other than an id. */
|
|
57
|
+
const UNKNOWN_MODEL = "unknown";
|
|
58
|
+
/**
|
|
59
|
+
* The one way an upstream body becomes a value this package will carry.
|
|
60
|
+
*
|
|
61
|
+
* It returns `undefined` rather than throwing, because a thrown validation
|
|
62
|
+
* error carries the rejected data in its own message — the exact thing that
|
|
63
|
+
* must not travel. Every field is copied explicitly: a passthrough object, a
|
|
64
|
+
* spread, or a `z.infer` handed straight to a record would all reintroduce
|
|
65
|
+
* "whatever HQ sent" as "whatever we publish".
|
|
66
|
+
*/
|
|
67
|
+
const parseJevResponse = (payload) => {
|
|
68
|
+
const parsed = jevResponseSchema.safeParse(payload);
|
|
69
|
+
if (!parsed.success) return;
|
|
70
|
+
const { answers, model, usage } = parsed.data;
|
|
71
|
+
const counters = {};
|
|
72
|
+
if (typeof usage?.input_tokens === "number") counters.input_tokens = usage.input_tokens;
|
|
73
|
+
if (typeof usage?.output_tokens === "number") counters.output_tokens = usage.output_tokens;
|
|
74
|
+
return {
|
|
75
|
+
answers,
|
|
76
|
+
model: MODEL_ID_SHAPE.test(model) ? model : UNKNOWN_MODEL,
|
|
77
|
+
...usage === void 0 ? {} : { usage: counters }
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Projects one answer onto the question that asked for it.
|
|
82
|
+
*
|
|
83
|
+
* Two jobs, and the second is why this lives here rather than in a caller.
|
|
84
|
+
* Type agreement keeps a well-formed answer of the wrong kind from throwing
|
|
85
|
+
* later, deeper, and taking a whole report with it. And the only free text an
|
|
86
|
+
* answer can carry — a `choice` label — is accepted solely when it is a label
|
|
87
|
+
* this package itself wrote into the question. Probability keys are rebuilt
|
|
88
|
+
* from the question's own labels or rubric levels for the same reason, so an
|
|
89
|
+
* upstream-invented key cannot ride along.
|
|
90
|
+
*/
|
|
91
|
+
const projectAnswer = (question, answer) => {
|
|
92
|
+
if (answer.type !== question.type) return { reason: `asked ${question.type}, answered ${answer.type}` };
|
|
93
|
+
if (answer.type === "noul") return { answer: {
|
|
94
|
+
noul: answer.noul,
|
|
95
|
+
type: "noul"
|
|
96
|
+
} };
|
|
97
|
+
const keep = (keys) => Object.fromEntries(keys.map((key) => [key, answer.probabilities[key]]).filter(([, value]) => typeof value === "number"));
|
|
98
|
+
if (answer.type === "choice") {
|
|
99
|
+
const labels = Object.keys(question.criteria);
|
|
100
|
+
if (!labels.includes(answer.choice)) return { reason: "answered with a label the question did not offer" };
|
|
101
|
+
return { answer: {
|
|
102
|
+
choice: answer.choice,
|
|
103
|
+
confidence: answer.confidence,
|
|
104
|
+
probabilities: keep(labels),
|
|
105
|
+
type: "choice"
|
|
106
|
+
} };
|
|
107
|
+
}
|
|
108
|
+
const levels = question.criteria.map((_, index) => String(index));
|
|
109
|
+
return { answer: {
|
|
110
|
+
confidence: answer.confidence,
|
|
111
|
+
probabilities: keep(levels),
|
|
112
|
+
score: answer.score,
|
|
113
|
+
type: "score"
|
|
114
|
+
} };
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/jev/cache.ts
|
|
118
|
+
/**
|
|
119
|
+
* The request-hash cache: the same request bytes against the same endpoint
|
|
120
|
+
* answer from disk instead of from HQ.
|
|
121
|
+
*
|
|
122
|
+
* The key is the endpoint plus the exact serialized request, and nothing else.
|
|
123
|
+
* That is the whole design: a changed snippet, a reworded question, a
|
|
124
|
+
* different reference file and a different HQ all move the bytes, so they all
|
|
125
|
+
* move the key. There is no key schema to keep in step with the request
|
|
126
|
+
* builder, and no way to grow one that forgets a field.
|
|
127
|
+
*
|
|
128
|
+
* It lives under `node_modules/.cache`, which is disposable by convention and
|
|
129
|
+
* already ignored everywhere — a Jev answer is a session diagnostic (ADR
|
|
130
|
+
* 0034), not a build input and not evidence.
|
|
131
|
+
*
|
|
132
|
+
* An entry that exists but cannot be used — unreadable, not JSON, not Jev's
|
|
133
|
+
* response shape — is an error, not a miss, and so is a write that fails. Only
|
|
134
|
+
* `ENOENT` means "nothing is stored". Repairing any of the others silently and
|
|
135
|
+
* calling HQ anyway would be a second behavior for one failure, and a paid
|
|
136
|
+
* one; the run names the entry and its cause, and the operator removes it.
|
|
137
|
+
*/
|
|
138
|
+
/** A cache entry that could not be used, or could not be written. */
|
|
139
|
+
var JevCacheError = class extends Error {
|
|
140
|
+
path;
|
|
141
|
+
constructor(entryPath, detail) {
|
|
142
|
+
super(`the Jev cache entry ${entryPath} could not be used: ${detail}. Remove it and run again.`);
|
|
143
|
+
this.name = "JevCacheError";
|
|
144
|
+
this.path = entryPath;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* The filesystem's own error code, which is a short constant it names
|
|
149
|
+
* (`EISDIR`, `EACCES`), not a message that could quote anything.
|
|
150
|
+
*/
|
|
151
|
+
const errnoOf = (error) => error?.code ?? "an unknown error";
|
|
152
|
+
/** Where a repository's Jev answers are kept. */
|
|
153
|
+
const defaultJevCacheDirectory = (cwd) => path.join(cwd, "node_modules", ".cache", "patronage-jev");
|
|
154
|
+
/**
|
|
155
|
+
* Opens the cache, creating its directory now so a directory that cannot be
|
|
156
|
+
* written fails here — at the one place that names it — rather than midway
|
|
157
|
+
* through a run as an unexplained request failure.
|
|
158
|
+
*/
|
|
159
|
+
const openJevCache = ({ directory = defaultJevCacheDirectory(process.cwd()), endpoint }) => {
|
|
160
|
+
mkdirSync(directory, { recursive: true });
|
|
161
|
+
const keyFor = (serialized) => createHash("sha256").update(`${endpoint}\0${serialized}`).digest("hex");
|
|
162
|
+
const entryPathFor = (serialized) => path.join(directory, `${keyFor(serialized)}.json`);
|
|
163
|
+
return {
|
|
164
|
+
keyFor,
|
|
165
|
+
read: (serialized) => {
|
|
166
|
+
const entryPath = entryPathFor(serialized);
|
|
167
|
+
let text;
|
|
168
|
+
try {
|
|
169
|
+
text = readFileSync(entryPath, "utf-8");
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (errnoOf(error) === "ENOENT") return;
|
|
172
|
+
throw new JevCacheError(entryPath, `reading it failed with ${errnoOf(error)}`);
|
|
173
|
+
}
|
|
174
|
+
let payload;
|
|
175
|
+
try {
|
|
176
|
+
payload = JSON.parse(text);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new JevCacheError(entryPath, "it is not JSON");
|
|
179
|
+
}
|
|
180
|
+
const parsed = parseJevResponse(payload);
|
|
181
|
+
if (parsed === void 0) throw new JevCacheError(entryPath, "it is not Jev's response shape");
|
|
182
|
+
return parsed;
|
|
183
|
+
},
|
|
184
|
+
write: (serialized, response) => {
|
|
185
|
+
const entryPath = entryPathFor(serialized);
|
|
186
|
+
try {
|
|
187
|
+
writeFileSync(entryPath, JSON.stringify(response), "utf-8");
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new JevCacheError(entryPath, `writing it failed with ${errnoOf(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/hq-access.ts
|
|
196
|
+
const CF_ACCESS_CLIENT_ID_ENV = "CF-Access-Client-Id";
|
|
197
|
+
const CF_ACCESS_CLIENT_SECRET_ENV = "CF-Access-Client-Secret";
|
|
198
|
+
/**
|
|
199
|
+
* True in a hosted runner, where secret-manager resolution must not be
|
|
200
|
+
* tried (#312). Lives here — a dependency-free leaf both
|
|
201
|
+
* `hq-ingest-preflight.ts` and `hq-credentials.ts` already import from for
|
|
202
|
+
* the env var names above — so neither of those two needs to import the
|
|
203
|
+
* other for it; that import, either direction, would close a cycle (#394).
|
|
204
|
+
*/
|
|
205
|
+
const isHostedRunner = (env) => env.CI === "true" || env.CI === "1" || env.GITHUB_ACTIONS === "true";
|
|
206
|
+
/**
|
|
207
|
+
* Builds a credential-bearing HQ request that cannot follow redirects. Access
|
|
208
|
+
* credentials must never leave the origin selected by the caller.
|
|
209
|
+
*/
|
|
210
|
+
const buildCloudflareAccessRequestInit = (token, init) => ({
|
|
211
|
+
...init,
|
|
212
|
+
headers: {
|
|
213
|
+
...init.headers,
|
|
214
|
+
[CF_ACCESS_CLIENT_ID_ENV]: token.clientId,
|
|
215
|
+
[CF_ACCESS_CLIENT_SECRET_ENV]: token.clientSecret
|
|
216
|
+
},
|
|
217
|
+
redirect: "error"
|
|
218
|
+
});
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/hq-credentials.ts
|
|
221
|
+
/**
|
|
222
|
+
* Resolving the HQ Access credentials, and saying *why* when that fails.
|
|
223
|
+
*
|
|
224
|
+
* A deliberate command (`psf hq:flush`, and doctor's remote checks in #394) is
|
|
225
|
+
* the one place allowed to ask the secret manager for the HQ Access token. It
|
|
226
|
+
* used to keep a value-or-nothing answer, so a missing binary, a sandbox that
|
|
227
|
+
* cannot reach the keychain, and a mistyped reference all collapsed into one
|
|
228
|
+
* generic "credentials are unavailable" line. A sandboxed lane read that as
|
|
229
|
+
* environmental noise while every one of its proofs stayed spooled.
|
|
230
|
+
*
|
|
231
|
+
* Two boundaries hold everywhere in this module:
|
|
232
|
+
*
|
|
233
|
+
* - **Classification comes from the failure mode, never from resolver output.**
|
|
234
|
+
* No stderr is captured and no resolver text is retained or rendered: a
|
|
235
|
+
* secret manager's diagnostics can quote the material it was asked for.
|
|
236
|
+
* Exit status, spawn error code, and signal are the whole evidence base.
|
|
237
|
+
* - **Trusted-local only.** Hosted runners never invoke a secret manager
|
|
238
|
+
* (#312); references stay inert there and the failure says so.
|
|
239
|
+
*/
|
|
240
|
+
/** Ceiling for one secret-manager probe; expiry means unresolved, never a hang. */
|
|
241
|
+
const SECRET_PROBE_TIMEOUT_MS = 1e4;
|
|
242
|
+
/** The operator's resolution path: `op-fast` first, official `op` as fallback. */
|
|
243
|
+
const SECRET_RESOLVER_BINARIES = ["op-fast", "op"];
|
|
244
|
+
const spawnFailure = (error) => {
|
|
245
|
+
const code = error?.code;
|
|
246
|
+
if (code === "ENOENT") return "resolver-missing";
|
|
247
|
+
if (code === "EACCES" || code === "EPERM") return "resolver-blocked";
|
|
248
|
+
return "resolver-timeout";
|
|
249
|
+
};
|
|
250
|
+
const probe = (binary, reference, capture) => {
|
|
251
|
+
const result = spawnSync(binary, ["read", reference], {
|
|
252
|
+
encoding: "utf-8",
|
|
253
|
+
stdio: [
|
|
254
|
+
"ignore",
|
|
255
|
+
capture ? "pipe" : "ignore",
|
|
256
|
+
"ignore"
|
|
257
|
+
],
|
|
258
|
+
timeout: SECRET_PROBE_TIMEOUT_MS
|
|
259
|
+
});
|
|
260
|
+
if (result.error) return { status: spawnFailure(result.error) };
|
|
261
|
+
if (result.signal) return { status: "resolver-timeout" };
|
|
262
|
+
if (result.status !== 0) return { status: "resolver-refused" };
|
|
263
|
+
if (!capture) return {
|
|
264
|
+
status: "resolved",
|
|
265
|
+
value: ""
|
|
266
|
+
};
|
|
267
|
+
const value = result.stdout.trim();
|
|
268
|
+
return value === "" ? { status: "resolver-refused" } : {
|
|
269
|
+
status: "resolved",
|
|
270
|
+
value
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* `op-fast` first, official `op` as the fallback, matching `doctor
|
|
275
|
+
* --preflight`'s resolvability probe. The reported failure is the first
|
|
276
|
+
* *informative* one: a machine without `op-fast` that has `op` installed and
|
|
277
|
+
* denied must report the denial, not the missing binary.
|
|
278
|
+
*/
|
|
279
|
+
const resolveThrough = (reference, capture) => {
|
|
280
|
+
let failure = "resolver-missing";
|
|
281
|
+
for (const binary of SECRET_RESOLVER_BINARIES) {
|
|
282
|
+
const outcome = probe(binary, reference, capture);
|
|
283
|
+
if (outcome.status === "resolved") return outcome;
|
|
284
|
+
if (failure === "resolver-missing") failure = outcome.status;
|
|
285
|
+
}
|
|
286
|
+
return { status: failure };
|
|
287
|
+
};
|
|
288
|
+
const defaultSecretReferenceResolver = (reference) => resolveThrough(reference, true);
|
|
289
|
+
/**
|
|
290
|
+
* Environment first (the #312 emit-time path), then the operator's configured
|
|
291
|
+
* references. The first reference that fails ends the resolution: a second
|
|
292
|
+
* probe would add nothing but another chance to hang.
|
|
293
|
+
*/
|
|
294
|
+
const resolveHqCredentials = ({ env, references, resolve = defaultSecretReferenceResolver }) => {
|
|
295
|
+
const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
|
|
296
|
+
const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
|
|
297
|
+
if (clientId && clientSecret) return {
|
|
298
|
+
credentials: {
|
|
299
|
+
clientId,
|
|
300
|
+
clientSecret
|
|
301
|
+
},
|
|
302
|
+
source: "environment",
|
|
303
|
+
status: "resolved"
|
|
304
|
+
};
|
|
305
|
+
if (!references) return {
|
|
306
|
+
failure: "no-reference",
|
|
307
|
+
status: "unresolved"
|
|
308
|
+
};
|
|
309
|
+
if (isHostedRunner(env)) return {
|
|
310
|
+
failure: "hosted-runner",
|
|
311
|
+
status: "unresolved"
|
|
312
|
+
};
|
|
313
|
+
const resolvedId = resolve(references.clientIdRef);
|
|
314
|
+
if (resolvedId.status !== "resolved") return {
|
|
315
|
+
failure: resolvedId.status,
|
|
316
|
+
reference: references.clientIdRef,
|
|
317
|
+
status: "unresolved"
|
|
318
|
+
};
|
|
319
|
+
const resolvedSecret = resolve(references.clientSecretRef);
|
|
320
|
+
if (resolvedSecret.status !== "resolved") return {
|
|
321
|
+
failure: resolvedSecret.status,
|
|
322
|
+
reference: references.clientSecretRef,
|
|
323
|
+
status: "unresolved"
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
credentials: {
|
|
327
|
+
clientId: resolvedId.value,
|
|
328
|
+
clientSecret: resolvedSecret.value
|
|
329
|
+
},
|
|
330
|
+
source: "references",
|
|
331
|
+
status: "resolved"
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/user-config.ts
|
|
336
|
+
const FACTORY_USER_CONFIG_SCHEMA_VERSION = 2;
|
|
337
|
+
const githubAppConfigSchema = z.object({
|
|
338
|
+
appId: z.union([z.string().min(1), z.number().int().positive()]),
|
|
339
|
+
installationId: z.number().int().positive().optional(),
|
|
340
|
+
privateKeyPath: z.string().min(1)
|
|
341
|
+
});
|
|
342
|
+
const hqAllowedOriginSchema = z.string().url().refine((value) => {
|
|
343
|
+
try {
|
|
344
|
+
const url = new URL(value);
|
|
345
|
+
return url.protocol === "https:" && url.username === "" && url.password === "" && value === url.origin;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}, "Must be an HTTPS origin without credentials, path, query, or fragment");
|
|
350
|
+
const hqIngestCredentialReferencesSchema = z.object({
|
|
351
|
+
clientIdRef: z.string().min(1),
|
|
352
|
+
clientSecretRef: z.string().min(1)
|
|
353
|
+
});
|
|
354
|
+
const factoryUserConfigSchema = z.object({
|
|
355
|
+
githubApp: githubAppConfigSchema.optional(),
|
|
356
|
+
hqAllowedOrigins: z.array(hqAllowedOriginSchema).describe("Operator-controlled exact HTTPS origins authorized to receive HQ credential-bearing requests; an absent or empty list authorizes none.").optional(),
|
|
357
|
+
hqIngestCredentials: hqIngestCredentialReferencesSchema.describe("Operator-controlled secret-manager references for the HQ ingest Cloudflare Access service token. References only — the factory never reads, prints, exports, or caches the values.").optional(),
|
|
358
|
+
schemaVersion: z.literal(FACTORY_USER_CONFIG_SCHEMA_VERSION)
|
|
359
|
+
});
|
|
360
|
+
function defaultUserConfigPath(env = process.env) {
|
|
361
|
+
const configHome = env.XDG_CONFIG_HOME !== void 0 && env.XDG_CONFIG_HOME !== "" ? env.XDG_CONFIG_HOME : path.join(env.HOME !== void 0 && env.HOME !== "" ? env.HOME : os.homedir(), ".config");
|
|
362
|
+
return path.join(configHome, "patronage-factory", "config.json");
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/hq-origin-policy.ts
|
|
366
|
+
/**
|
|
367
|
+
* Where the HQ Access service token may be sent, decided once.
|
|
368
|
+
*
|
|
369
|
+
* A request that carries the token goes only to an HTTPS origin with no
|
|
370
|
+
* embedded credentials that the operator listed, exactly, in
|
|
371
|
+
* `hqAllowedOrigins` in the operator user config. Committed repository data
|
|
372
|
+
* and the environment can name an origin; only the operator can authorize one
|
|
373
|
+
* (ADR 0015). HQ ingest and the Jev client both call this module, so the two
|
|
374
|
+
* cannot drift apart: one protocol rule, one reader, one parser.
|
|
375
|
+
*
|
|
376
|
+
* The reader is bounded. A config path that is a FIFO, a huge file, or a disk
|
|
377
|
+
* that hangs answers "no origin is authorized" within the caller's deadline
|
|
378
|
+
* instead of holding a gate or a lint run open.
|
|
379
|
+
*/
|
|
380
|
+
/** Largest operator config or profile file the HQ readers will open. */
|
|
381
|
+
const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
|
|
382
|
+
const remainingMs = (deadline) => Math.max(0, deadline - performance.now());
|
|
383
|
+
const settleWithin = async (operation, budgetMs) => {
|
|
384
|
+
const settleOperation = async () => {
|
|
385
|
+
try {
|
|
386
|
+
return {
|
|
387
|
+
status: "fulfilled",
|
|
388
|
+
value: await operation
|
|
389
|
+
};
|
|
390
|
+
} catch (error) {
|
|
391
|
+
return {
|
|
392
|
+
error,
|
|
393
|
+
status: "rejected"
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const timeoutAbort = new AbortController();
|
|
398
|
+
const settleTimeout = async () => {
|
|
399
|
+
try {
|
|
400
|
+
await setTimeout$1(Math.max(0, budgetMs), void 0, { signal: timeoutAbort.signal });
|
|
401
|
+
} catch {}
|
|
402
|
+
return { status: "timed-out" };
|
|
403
|
+
};
|
|
404
|
+
const result = await Promise.race([settleOperation(), settleTimeout()]);
|
|
405
|
+
timeoutAbort.abort();
|
|
406
|
+
return result;
|
|
407
|
+
};
|
|
408
|
+
const readBoundedTextFile = async (filePath, deadline, maxBytes = MAX_HQ_CONFIG_BYTES) => {
|
|
409
|
+
const metadata = await settleWithin(stat(filePath), remainingMs(deadline));
|
|
410
|
+
if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.size > maxBytes) return;
|
|
411
|
+
const budget = remainingMs(deadline);
|
|
412
|
+
if (budget <= 0) return;
|
|
413
|
+
const abort = new AbortController();
|
|
414
|
+
const abortTimer = setTimeout(() => abort.abort(), budget);
|
|
415
|
+
const contents = await settleWithin(readFile(filePath, {
|
|
416
|
+
encoding: "utf-8",
|
|
417
|
+
signal: abort.signal
|
|
418
|
+
}), budget);
|
|
419
|
+
clearTimeout(abortTimer);
|
|
420
|
+
if (contents.status !== "fulfilled" || Buffer.byteLength(contents.value, "utf-8") > maxBytes) return;
|
|
421
|
+
return contents.value;
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* The protocol rule: an `https:` URL with no username and no password. There
|
|
425
|
+
* is no `http:` exception, loopback included. The caller composes its own path
|
|
426
|
+
* onto the returned URL's `origin`.
|
|
427
|
+
*/
|
|
428
|
+
const validatedHqOrigin = (value) => {
|
|
429
|
+
try {
|
|
430
|
+
const endpoint = new URL(value);
|
|
431
|
+
if (endpoint.protocol !== "https:" || endpoint.username !== "" || endpoint.password !== "") return;
|
|
432
|
+
return endpoint;
|
|
433
|
+
} catch {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* The operator's `hqAllowedOrigins`, from the text of the operator user
|
|
439
|
+
* config. A file that is not JSON or not a valid user config authorizes
|
|
440
|
+
* nothing: the list is empty, never partly read.
|
|
441
|
+
*/
|
|
442
|
+
const hqAllowedOriginsFromConfigText = (text) => {
|
|
443
|
+
try {
|
|
444
|
+
const parsed = factoryUserConfigSchema.safeParse(JSON.parse(text));
|
|
445
|
+
return parsed.success ? parsed.data.hqAllowedOrigins ?? [] : [];
|
|
446
|
+
} catch {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
/**
|
|
451
|
+
* The operator's `hqAllowedOrigins`, read from the operator user config within
|
|
452
|
+
* `deadline` (a `performance.now()` time). A missing, unreadable, oversized or
|
|
453
|
+
* invalid file, or one that does not answer in time, authorizes nothing.
|
|
454
|
+
*/
|
|
455
|
+
const loadHqAllowedOrigins = async (env, deadline) => {
|
|
456
|
+
let configPath;
|
|
457
|
+
try {
|
|
458
|
+
configPath = defaultUserConfigPath(env);
|
|
459
|
+
} catch {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
const contents = await readBoundedTextFile(configPath, deadline);
|
|
463
|
+
return contents === void 0 ? [] : hqAllowedOriginsFromConfigText(contents);
|
|
464
|
+
};
|
|
465
|
+
/**
|
|
466
|
+
* The matching rule: the URL's origin must equal one listed origin exactly.
|
|
467
|
+
* No prefix, wildcard, or host-only match.
|
|
468
|
+
*/
|
|
469
|
+
const isHqOriginAuthorized = (allowedOrigins, endpoint) => allowedOrigins.includes(endpoint.origin);
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/jev/client.ts
|
|
472
|
+
/**
|
|
473
|
+
* The one Jev gateway: HQ (ADR 0033).
|
|
474
|
+
*
|
|
475
|
+
* There is no fallback provider and no direct mode "for local dev". This
|
|
476
|
+
* module knows Jev's request schema and how to reach HQ; it does not know
|
|
477
|
+
* Cloudflare, TypeSafe, accounts, or model versions. HQ names the model and
|
|
478
|
+
* returns the one that actually ran.
|
|
479
|
+
*
|
|
480
|
+
* `HQ_INGEST_URL` names the origin (#259). The origin is authorized before
|
|
481
|
+
* anything else happens, by the same code HQ ingest uses
|
|
482
|
+
* (`hq-origin-policy.ts`, ADR 0015): it must be an `https:` origin with no
|
|
483
|
+
* embedded credentials, listed exactly in the operator's `hqAllowedOrigins`.
|
|
484
|
+
* Only then are credentials read: `resolveHqCredentials` reads the hyphenated
|
|
485
|
+
* `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair, and
|
|
486
|
+
* `buildCloudflareAccessRequestInit` attaches them to a request that refuses
|
|
487
|
+
* redirects so the credentials cannot leave the authorized origin.
|
|
488
|
+
*
|
|
489
|
+
* Credentials are read from the environment only. A diagnostic must not reach
|
|
490
|
+
* into the operator's secret manager: `hq:flush` is the deliberate command
|
|
491
|
+
* allowed to do that, and a session that has already loaded the pair for a
|
|
492
|
+
* factory command has it here too. So `resolveHqCredentials` is called with no
|
|
493
|
+
* references, and the remedy this module prints is the one that actually
|
|
494
|
+
* applies — load the variables — never "record references in the user config",
|
|
495
|
+
* which would do nothing for this command.
|
|
496
|
+
*/
|
|
497
|
+
/** The environment variable that carries HQ's origin, same as ingest. */
|
|
498
|
+
const HQ_ORIGIN_ENV = "HQ_INGEST_URL";
|
|
499
|
+
/** HQ's Jev boundary, composed onto the origin. */
|
|
500
|
+
const JEV_ROUTE_PATH = "/api/jev";
|
|
501
|
+
/** Per-attempt ceiling. A 32k-token packet is a slow request, not a hung one. */
|
|
502
|
+
const JEV_REQUEST_TIMEOUT_MS = 6e4;
|
|
503
|
+
/**
|
|
504
|
+
* Ceiling for reading the operator user config. A config path that hangs
|
|
505
|
+
* authorizes nothing rather than holding the run open.
|
|
506
|
+
*/
|
|
507
|
+
const ORIGIN_POLICY_READ_MS = 5e3;
|
|
508
|
+
/**
|
|
509
|
+
* An upstream refusal, carried with the code HQ passed through rather than
|
|
510
|
+
* flattened into prose. `max_tokens_exceeded` is the one a caller acts on: it
|
|
511
|
+
* means the packet was too large despite the local budget.
|
|
512
|
+
*/
|
|
513
|
+
var JevRequestError = class extends Error {
|
|
514
|
+
code;
|
|
515
|
+
httpStatus;
|
|
516
|
+
constructor(httpStatus, code, message) {
|
|
517
|
+
super(message);
|
|
518
|
+
this.name = "JevRequestError";
|
|
519
|
+
this.code = code;
|
|
520
|
+
this.httpStatus = httpStatus;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* A code is a short machine token. Anything else in that field is prose the
|
|
525
|
+
* upstream chose, and prose is exactly what must not travel into a report.
|
|
526
|
+
*/
|
|
527
|
+
const CODE_SHAPE = /^[a-z0-9_.-]{1,64}$/iu;
|
|
528
|
+
/** Reads the upstream error code out of whatever shape the body arrived in. */
|
|
529
|
+
const rawErrorCode = (body) => {
|
|
530
|
+
if (body === null || typeof body !== "object") return;
|
|
531
|
+
const record = body;
|
|
532
|
+
if (typeof record.code === "string") return record.code;
|
|
533
|
+
if (typeof record.error === "string") return record.error;
|
|
534
|
+
if (record.error !== null && typeof record.error === "object") {
|
|
535
|
+
const nested = record.error.code;
|
|
536
|
+
return typeof nested === "string" ? nested : void 0;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
/**
|
|
540
|
+
* The only thing kept from a refusal body.
|
|
541
|
+
*
|
|
542
|
+
* An upstream 400 routinely quotes the state it rejected, and this state is
|
|
543
|
+
* repository source. That message used to be copied into the request record and
|
|
544
|
+
* into every affected case's reasons, so an ordinary run without
|
|
545
|
+
* `--include-evidence` could print source back out. Status and a code-shaped
|
|
546
|
+
* token are enough to act on — `max_tokens_exceeded` is the one a caller does
|
|
547
|
+
* anything about — and a field that is not code-shaped is dropped rather than
|
|
548
|
+
* trimmed, because a truncated leak is still a leak.
|
|
549
|
+
*/
|
|
550
|
+
const errorCode = (body) => {
|
|
551
|
+
const raw = rawErrorCode(body);
|
|
552
|
+
return raw !== void 0 && CODE_SHAPE.test(raw) ? raw : void 0;
|
|
553
|
+
};
|
|
554
|
+
/**
|
|
555
|
+
* How to name a rejected origin without repeating it.
|
|
556
|
+
*
|
|
557
|
+
* Every `unavailable` reason reaches `statusReason`, each case's `reasons`, and
|
|
558
|
+
* both the JSON and Markdown renderings — none of which is redacted anywhere.
|
|
559
|
+
* An `HQ_INGEST_URL` carrying userinfo is exactly the value that must not be
|
|
560
|
+
* echoed, and it is exactly the value being rejected, so the diagnostic is
|
|
561
|
+
* limited to scheme and host. A value that does not parse is not quoted at all:
|
|
562
|
+
* there is no structure to trim it down to.
|
|
563
|
+
*/
|
|
564
|
+
const describeOrigin = (origin) => {
|
|
565
|
+
const parsed = URL.parse(origin);
|
|
566
|
+
return parsed === null ? "(not a URL; the value is withheld because it could not be parsed to strip credentials)" : `${parsed.protocol}//${parsed.host}`;
|
|
567
|
+
};
|
|
568
|
+
/**
|
|
569
|
+
* HQ's Jev endpoint for a configured origin, or `undefined` when the value
|
|
570
|
+
* breaks HQ ingest's protocol rule (`validatedHqOrigin`: `https:` only, no
|
|
571
|
+
* embedded credentials, no `http:` exception).
|
|
572
|
+
*
|
|
573
|
+
* The path is composed onto the URL's *origin*, which is what both existing HQ
|
|
574
|
+
* readers do and what makes the documented misconfiguration harmless: the
|
|
575
|
+
* operator who sets `HQ_INGEST_URL` to the profile's `/api/ingest` endpoint
|
|
576
|
+
* instead of the origin (#259) still reaches `/api/jev`, not
|
|
577
|
+
* `/api/ingest/api/jev`.
|
|
578
|
+
*/
|
|
579
|
+
const jevEndpointFor = (origin) => {
|
|
580
|
+
const parsed = validatedHqOrigin(origin);
|
|
581
|
+
return parsed === void 0 ? void 0 : new URL(JEV_ROUTE_PATH, parsed.origin).href;
|
|
582
|
+
};
|
|
583
|
+
/**
|
|
584
|
+
* Whether an upstream token is this request's own credential handed back.
|
|
585
|
+
*
|
|
586
|
+
* Narrow on purpose. A model id and an error code are opaque tokens HQ names,
|
|
587
|
+
* so they cannot be pattern-matched for secrets in general, and general secret
|
|
588
|
+
* scanning is not wanted here: HQ already holds both the credential and the
|
|
589
|
+
* source, so a hostile HQ is outside the threat model. What *is* cheap and
|
|
590
|
+
* exact is the comparison this function makes — the credential values are in
|
|
591
|
+
* scope at the one place a response is read, so a field that echoes one back
|
|
592
|
+
* can be recognized by equality rather than by guesswork.
|
|
593
|
+
*/
|
|
594
|
+
const reflectsCredential = (value, token) => value.includes(token.clientId) || value.includes(token.clientSecret);
|
|
595
|
+
/**
|
|
596
|
+
* The whole exchange, and the only place an upstream body is touched.
|
|
597
|
+
*
|
|
598
|
+
* Fetching, reading the body, parsing it, and validating it all happen inside
|
|
599
|
+
* one boundary with one outer catch, because the leak this shape prevents kept
|
|
600
|
+
* coming back by a different route each time: first the refusal body, then the
|
|
601
|
+
* transport message, then the body stream, then a schema issue. Each of those
|
|
602
|
+
* is a distinct throw site, and patching them one at a time is a losing game.
|
|
603
|
+
*
|
|
604
|
+
* The rule is therefore structural, not per-site: **everything that leaves here
|
|
605
|
+
* is a `JevRequestError` whose message is a fixed string, plus an HTTP status,
|
|
606
|
+
* plus a code-shaped token.** Nothing derived from an upstream value —
|
|
607
|
+
* `error.message`, `error.cause`, response text, or a validation issue — is
|
|
608
|
+
* ever read into a message. A throw this function does not recognize becomes
|
|
609
|
+
* one fixed sentence rather than being inspected.
|
|
610
|
+
*/
|
|
611
|
+
const exchange = async (input) => {
|
|
612
|
+
const init = buildCloudflareAccessRequestInit(input.token, {
|
|
613
|
+
body: input.serializedBody,
|
|
614
|
+
headers: { "content-type": "application/json" },
|
|
615
|
+
method: "POST",
|
|
616
|
+
signal: AbortSignal.timeout(JEV_REQUEST_TIMEOUT_MS)
|
|
617
|
+
});
|
|
618
|
+
try {
|
|
619
|
+
let response;
|
|
620
|
+
try {
|
|
621
|
+
response = await input.fetchImpl(input.endpoint, init);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
const timedOut = error instanceof Error && error.name === "TimeoutError";
|
|
624
|
+
throw new JevRequestError(0, timedOut ? "timeout" : "unreachable", timedOut ? `HQ Jev at ${input.endpoint} did not answer within ${JEV_REQUEST_TIMEOUT_MS}ms.` : `HQ Jev at ${input.endpoint} could not be reached. Redirects are refused to protect Cloudflare Access credentials.`);
|
|
625
|
+
}
|
|
626
|
+
const text = await response.text();
|
|
627
|
+
let payload;
|
|
628
|
+
try {
|
|
629
|
+
payload = JSON.parse(text);
|
|
630
|
+
} catch {
|
|
631
|
+
payload = void 0;
|
|
632
|
+
}
|
|
633
|
+
if (!response.ok) {
|
|
634
|
+
const reported = errorCode(payload);
|
|
635
|
+
const code = reported !== void 0 && reflectsCredential(reported, input.token) ? void 0 : reported;
|
|
636
|
+
throw new JevRequestError(response.status, code, `HQ Jev refused the request with HTTP ${response.status}${code === void 0 ? "" : ` (${code})`}. The response body is not reported: an upstream validation error can quote the state it rejected, which is repository source.`);
|
|
637
|
+
}
|
|
638
|
+
const parsed = parseJevResponse(payload);
|
|
639
|
+
const body = parsed !== void 0 && reflectsCredential(parsed.model, input.token) ? {
|
|
640
|
+
...parsed,
|
|
641
|
+
model: UNKNOWN_MODEL
|
|
642
|
+
} : parsed;
|
|
643
|
+
if (body === void 0) throw new JevRequestError(response.status, "malformed_response", "HQ Jev returned a body that is not Jev's response shape. The body is not reported.");
|
|
644
|
+
return body;
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (error instanceof JevRequestError) throw error;
|
|
647
|
+
throw new JevRequestError(0, "exchange_failed", "The HQ Jev exchange failed before a response could be read. No upstream text is reported: it can quote the request.");
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* The whole remedy, in one sentence. The variable names are literally
|
|
652
|
+
* hyphenated, so a plain `export` does not set them.
|
|
653
|
+
*/
|
|
654
|
+
const MISSING_CREDENTIALS_REASON = `no HQ credentials: ${CF_ACCESS_CLIENT_ID_ENV} and ${CF_ACCESS_CLIENT_SECRET_ENV} are not both in the environment. The names are literally hyphenated, so a plain \`export\` does not set them — prefix the command: \`env '${CF_ACCESS_CLIENT_ID_ENV}=…' '${CF_ACCESS_CLIENT_SECRET_ENV}=…' <the command> …\`.`;
|
|
655
|
+
/** Where the operator config was looked for, for a message that names it. */
|
|
656
|
+
const operatorConfigLocation = (env) => {
|
|
657
|
+
try {
|
|
658
|
+
return ` (${defaultUserConfigPath(env)})`;
|
|
659
|
+
} catch {
|
|
660
|
+
return "";
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
/**
|
|
664
|
+
* Why an origin is not authorized. An empty list and an unlisted origin are
|
|
665
|
+
* different remedies, so they are different sentences. A config that is
|
|
666
|
+
* missing, unreadable or invalid reads as an empty list, exactly as it does
|
|
667
|
+
* for HQ ingest.
|
|
668
|
+
*/
|
|
669
|
+
const unauthorizedOriginReason = (allowedOrigins, origin, env) => allowedOrigins.length === 0 ? `no HQ origin is authorized: the operator user config${operatorConfigLocation(env)} has no hqAllowedOrigins, or it is missing or cannot be read. List ${origin} there, as HQ ingest requires, before Jev sends credentials or source to it.` : `HQ origin ${origin} is not authorized by hqAllowedOrigins in the operator user config${operatorConfigLocation(env)}. List the exact origin there, as HQ ingest requires, before Jev sends credentials or source to it.`;
|
|
670
|
+
/**
|
|
671
|
+
* Resolves the gateway, or says why there isn't one. Every `unavailable`
|
|
672
|
+
* reason is an operator-facing sentence naming a remedy that applies to this
|
|
673
|
+
* command, and none of them can carry a credential: nothing here reads a
|
|
674
|
+
* resolved value into a message.
|
|
675
|
+
*
|
|
676
|
+
* The order is the policy. The origin is checked against HQ ingest's protocol
|
|
677
|
+
* rule and the operator's `hqAllowedOrigins` first, and only an authorized
|
|
678
|
+
* origin gets as far as reading credentials. An unauthorized origin never sees
|
|
679
|
+
* a request.
|
|
680
|
+
*/
|
|
681
|
+
const resolveJevGateway = async ({ env, fetchImpl = fetch }) => {
|
|
682
|
+
const origin = env[HQ_ORIGIN_ENV]?.trim();
|
|
683
|
+
if (!origin) return {
|
|
684
|
+
reason: `no HQ origin: ${HQ_ORIGIN_ENV} is unset. It wants the origin, for example https://hq.patronage.com.`,
|
|
685
|
+
status: "unavailable"
|
|
686
|
+
};
|
|
687
|
+
const endpoint = jevEndpointFor(origin);
|
|
688
|
+
if (endpoint === void 0) return {
|
|
689
|
+
reason: `${HQ_ORIGIN_ENV} must be an https origin without embedded credentials, as HQ ingest requires: ${describeOrigin(origin)}`,
|
|
690
|
+
status: "unavailable"
|
|
691
|
+
};
|
|
692
|
+
const endpointUrl = new URL(endpoint);
|
|
693
|
+
const allowedOrigins = await loadHqAllowedOrigins(env, performance.now() + ORIGIN_POLICY_READ_MS);
|
|
694
|
+
if (!isHqOriginAuthorized(allowedOrigins, endpointUrl)) return {
|
|
695
|
+
reason: unauthorizedOriginReason(allowedOrigins, endpointUrl.origin, env),
|
|
696
|
+
status: "unavailable"
|
|
697
|
+
};
|
|
698
|
+
const resolution = resolveHqCredentials({ env });
|
|
699
|
+
if (resolution.status !== "resolved") return {
|
|
700
|
+
reason: MISSING_CREDENTIALS_REASON,
|
|
701
|
+
status: "unavailable"
|
|
702
|
+
};
|
|
703
|
+
return {
|
|
704
|
+
endpoint,
|
|
705
|
+
evaluate: (serializedBody) => exchange({
|
|
706
|
+
endpoint,
|
|
707
|
+
fetchImpl,
|
|
708
|
+
serializedBody,
|
|
709
|
+
token: resolution.credentials
|
|
710
|
+
}),
|
|
711
|
+
status: "ready"
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region src/jev/request.ts
|
|
716
|
+
/**
|
|
717
|
+
* The per-request ceiling, in tokens. Jev's context is the constraint this
|
|
718
|
+
* bounds; 32k is the size the epic designed to, and the headroom below covers
|
|
719
|
+
* the estimator's error rather than pretending it has none.
|
|
720
|
+
*/
|
|
721
|
+
const JEV_REQUEST_TOKEN_BUDGET = 32e3;
|
|
722
|
+
/** Fraction of the budget a request may fill. The rest absorbs estimator error. */
|
|
723
|
+
const BUDGET_HEADROOM = .9;
|
|
724
|
+
/** JSON punctuation the per-part sizes do not account for. */
|
|
725
|
+
const ENVELOPE_OVERHEAD_BYTES = 128;
|
|
726
|
+
const BYTES_PER_TOKEN = 4;
|
|
727
|
+
/**
|
|
728
|
+
* Tokens from bytes, at the conventional four-bytes-per-token ratio.
|
|
729
|
+
*
|
|
730
|
+
* A real tokenizer would be a heavy dependency and a second thing to keep in
|
|
731
|
+
* step with whatever model HQ routes to — and the factory holds no model
|
|
732
|
+
* vocabulary by design (ADR 0033). An over-estimate costs an extra split; the
|
|
733
|
+
* headroom above covers an under-estimate. It is a budget, not a measurement.
|
|
734
|
+
*/
|
|
735
|
+
const tokensFor = (bytes) => Math.ceil(bytes / BYTES_PER_TOKEN);
|
|
736
|
+
/**
|
|
737
|
+
* JSON with object keys in sorted order, so the same logical request always
|
|
738
|
+
* serializes to the same bytes — and therefore the same hash, and therefore
|
|
739
|
+
* the same cache entry. Two runs that assembled their state in a different
|
|
740
|
+
* order must not look like two different requests.
|
|
741
|
+
*/
|
|
742
|
+
const stableStringify = (value) => JSON.stringify(value, (_key, nested) => {
|
|
743
|
+
if (nested === null || typeof nested !== "object" || Array.isArray(nested)) return nested;
|
|
744
|
+
const source = nested;
|
|
745
|
+
return Object.fromEntries(Object.keys(source).toSorted().map((key) => [key, source[key]]));
|
|
746
|
+
});
|
|
747
|
+
const byteSize = (value) => Buffer.byteLength(stableStringify(value), "utf-8");
|
|
748
|
+
/** What one match adds to a request: its state entry plus its questions. */
|
|
749
|
+
const matchCost = (match) => byteSize({ [match.key]: match.state }) + byteSize(match.questions);
|
|
750
|
+
/** What a request costs before any match is in it. */
|
|
751
|
+
const baseCost = (file) => ENVELOPE_OVERHEAD_BYTES + byteSize({ path: file.path }) + byteSize({ file: file.shared });
|
|
752
|
+
const buildBody = (file, matches) => {
|
|
753
|
+
const matchStates = Object.fromEntries(matches.map((match) => [match.key, match.state]));
|
|
754
|
+
return {
|
|
755
|
+
questions: Object.fromEntries(matches.flatMap((match) => Object.entries(match.questions))),
|
|
756
|
+
state: {
|
|
757
|
+
file: file.shared,
|
|
758
|
+
matches: matchStates,
|
|
759
|
+
path: file.path
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
};
|
|
763
|
+
const toRequest = (file, matches) => {
|
|
764
|
+
const body = buildBody(file, matches);
|
|
765
|
+
const serialized = stableStringify(body);
|
|
766
|
+
return {
|
|
767
|
+
body,
|
|
768
|
+
estimatedTokens: tokensFor(Buffer.byteLength(serialized, "utf-8")),
|
|
769
|
+
hash: createHash("sha256").update(serialized).digest("hex"),
|
|
770
|
+
matchKeys: matches.map((match) => match.key),
|
|
771
|
+
path: file.path,
|
|
772
|
+
serialized
|
|
773
|
+
};
|
|
774
|
+
};
|
|
775
|
+
/**
|
|
776
|
+
* Turns files into the requests that will be sent, plus the matches that
|
|
777
|
+
* cannot be sent and why. Pure: it performs no I/O and decides nothing about
|
|
778
|
+
* transport.
|
|
779
|
+
*/
|
|
780
|
+
const buildJevRequests = ({ budgetTokens = JEV_REQUEST_TOKEN_BUDGET, files }) => {
|
|
781
|
+
const ceiling = Math.floor(budgetTokens * BUDGET_HEADROOM);
|
|
782
|
+
const ceilingBytes = ceiling * BYTES_PER_TOKEN;
|
|
783
|
+
const requests = [];
|
|
784
|
+
const unavailable = [];
|
|
785
|
+
for (const file of files) {
|
|
786
|
+
const base = baseCost(file);
|
|
787
|
+
let open = [];
|
|
788
|
+
let openBytes = base;
|
|
789
|
+
const flush = () => {
|
|
790
|
+
if (open.length > 0) {
|
|
791
|
+
requests.push(toRequest(file, open));
|
|
792
|
+
open = [];
|
|
793
|
+
openBytes = base;
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
for (const match of file.matches) {
|
|
797
|
+
const cost = matchCost(match);
|
|
798
|
+
if (openBytes + cost <= ceilingBytes) {
|
|
799
|
+
open.push(match);
|
|
800
|
+
openBytes += cost;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
flush();
|
|
804
|
+
if (openBytes + cost <= ceilingBytes) {
|
|
805
|
+
open.push(match);
|
|
806
|
+
openBytes += cost;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
unavailable.push({
|
|
810
|
+
key: match.key,
|
|
811
|
+
reason: `this match does not fit one Jev request: ${file.path} plus this match is estimated at ${tokensFor(base + cost)} tokens against a ${ceiling}-token ceiling`
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
flush();
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
requests,
|
|
818
|
+
unavailable
|
|
819
|
+
};
|
|
820
|
+
};
|
|
821
|
+
//#endregion
|
|
822
|
+
//#region src/jev/run.ts
|
|
823
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
824
|
+
/**
|
|
825
|
+
* Matches one match's answers to the questions it asked.
|
|
826
|
+
*
|
|
827
|
+
* `projectAnswer` does the work: it rebuilds each answer from the question
|
|
828
|
+
* that asked for it, so an answer of the wrong kind, a `choice` label nobody
|
|
829
|
+
* offered, and an upstream-invented probability key are each refused or
|
|
830
|
+
* dropped rather than carried. A refusal costs one match — it becomes
|
|
831
|
+
* unavailable with a reason — instead of throwing later, deeper, and taking a
|
|
832
|
+
* whole report's correctly answered siblings with it.
|
|
833
|
+
*/
|
|
834
|
+
const routeAnswers = (questions, answers) => {
|
|
835
|
+
const routed = /* @__PURE__ */ new Map();
|
|
836
|
+
const missing = [];
|
|
837
|
+
const mismatched = [];
|
|
838
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
839
|
+
const answer = answers[name];
|
|
840
|
+
if (answer === void 0) {
|
|
841
|
+
missing.push(name);
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
const projected = projectAnswer(question, answer);
|
|
845
|
+
if (projected.reason === void 0) routed.set(name, projected.answer);
|
|
846
|
+
else mismatched.push(`${name} (${projected.reason})`);
|
|
847
|
+
}
|
|
848
|
+
if (missing.length > 0) return { reason: `Jev answered the request but omitted ${missing.join(", ")}` };
|
|
849
|
+
if (mismatched.length > 0) return { reason: `answer type mismatch: ${mismatched.join(", ")}` };
|
|
850
|
+
return { answers: Object.fromEntries(routed) };
|
|
851
|
+
};
|
|
852
|
+
const completionOf = (answered, unanswered) => {
|
|
853
|
+
if (unanswered === 0) return "complete";
|
|
854
|
+
return answered === 0 ? "unavailable" : "partial";
|
|
855
|
+
};
|
|
856
|
+
/** Counters copied one by one, never the parsed object: this record is printed. */
|
|
857
|
+
const copyUsage = (usage) => {
|
|
858
|
+
const copied = {};
|
|
859
|
+
if (typeof usage.input_tokens === "number") copied.input_tokens = usage.input_tokens;
|
|
860
|
+
if (typeof usage.output_tokens === "number") copied.output_tokens = usage.output_tokens;
|
|
861
|
+
return copied;
|
|
862
|
+
};
|
|
863
|
+
/**
|
|
864
|
+
* One request's answer, from the cache when the exact bytes are already
|
|
865
|
+
* answered and from HQ otherwise. A live answer is stored on the way back, so
|
|
866
|
+
* the next identical request costs nothing.
|
|
867
|
+
*/
|
|
868
|
+
const answerFor = async (gateway, cache, serialized) => {
|
|
869
|
+
const stored = cache?.read(serialized);
|
|
870
|
+
if (stored !== void 0) return {
|
|
871
|
+
cached: true,
|
|
872
|
+
response: stored
|
|
873
|
+
};
|
|
874
|
+
const response = await gateway.evaluate(serialized);
|
|
875
|
+
cache?.write(serialized, response);
|
|
876
|
+
return {
|
|
877
|
+
cached: false,
|
|
878
|
+
response
|
|
879
|
+
};
|
|
880
|
+
};
|
|
881
|
+
/** Writes what HQ reported about the call itself onto the request's record. */
|
|
882
|
+
const recordResponse = (record, response, models) => {
|
|
883
|
+
record.model = response.model;
|
|
884
|
+
if (response.usage !== void 0) record.usage = copyUsage(response.usage);
|
|
885
|
+
if (!models.includes(response.model)) models.push(response.model);
|
|
886
|
+
};
|
|
887
|
+
/**
|
|
888
|
+
* Builds the requests, sends the ones the cache does not already answer, and
|
|
889
|
+
* routes answers back to the matches that asked for them.
|
|
890
|
+
*
|
|
891
|
+
* Requests go one at a time. Bounded load is the point: a repository-wide run
|
|
892
|
+
* would otherwise open dozens of concurrent 32k-token requests against one HQ
|
|
893
|
+
* route for a diagnostic nothing is waiting on.
|
|
894
|
+
*/
|
|
895
|
+
const runJevFiles = async ({ budgetTokens, cache, files, gateway }) => {
|
|
896
|
+
const plan = buildJevRequests({
|
|
897
|
+
...budgetTokens === void 0 ? {} : { budgetTokens },
|
|
898
|
+
files
|
|
899
|
+
});
|
|
900
|
+
const questionsByMatch = /* @__PURE__ */ new Map();
|
|
901
|
+
for (const file of files) for (const match of file.matches) questionsByMatch.set(match.key, match.questions);
|
|
902
|
+
const answers = /* @__PURE__ */ new Map();
|
|
903
|
+
const unavailable = /* @__PURE__ */ new Map();
|
|
904
|
+
const requests = [];
|
|
905
|
+
const models = [];
|
|
906
|
+
for (const { key, reason } of plan.unavailable) unavailable.set(key, reason);
|
|
907
|
+
if (gateway.status !== "ready") {
|
|
908
|
+
for (const request of plan.requests) for (const key of request.matchKeys) unavailable.set(key, gateway.reason);
|
|
909
|
+
return {
|
|
910
|
+
answersByMatchKey: {},
|
|
911
|
+
completion: completionOf(0, unavailable.size),
|
|
912
|
+
elapsedMs: 0,
|
|
913
|
+
models,
|
|
914
|
+
requests,
|
|
915
|
+
unavailableByMatchKey: Object.fromEntries(unavailable)
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
const runStarted = Date.now();
|
|
919
|
+
for (const request of plan.requests) {
|
|
920
|
+
const record = {
|
|
921
|
+
cached: false,
|
|
922
|
+
elapsedMs: 0,
|
|
923
|
+
estimatedTokens: request.estimatedTokens,
|
|
924
|
+
matchKeys: request.matchKeys,
|
|
925
|
+
path: request.path,
|
|
926
|
+
requestHash: request.hash
|
|
927
|
+
};
|
|
928
|
+
const started = Date.now();
|
|
929
|
+
try {
|
|
930
|
+
const { cached, response } = await answerFor(gateway, cache, request.serialized);
|
|
931
|
+
record.cached = cached;
|
|
932
|
+
recordResponse(record, response, models);
|
|
933
|
+
for (const matchKey of request.matchKeys) {
|
|
934
|
+
const routed = routeAnswers(questionsByMatch.get(matchKey) ?? {}, response.answers);
|
|
935
|
+
if (routed.reason === void 0) answers.set(matchKey, routed.answers);
|
|
936
|
+
else unavailable.set(matchKey, routed.reason);
|
|
937
|
+
}
|
|
938
|
+
} catch (error) {
|
|
939
|
+
record.error = describe(error);
|
|
940
|
+
if (error instanceof JevRequestError && error.code !== void 0) record.errorCode = error.code;
|
|
941
|
+
for (const matchKey of request.matchKeys) unavailable.set(matchKey, record.error);
|
|
942
|
+
}
|
|
943
|
+
record.elapsedMs = Date.now() - started;
|
|
944
|
+
requests.push(record);
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
answersByMatchKey: Object.fromEntries(answers),
|
|
948
|
+
completion: completionOf(answers.size, unavailable.size),
|
|
949
|
+
elapsedMs: Date.now() - runStarted,
|
|
950
|
+
models,
|
|
951
|
+
requests,
|
|
952
|
+
unavailableByMatchKey: Object.fromEntries(unavailable)
|
|
953
|
+
};
|
|
954
|
+
};
|
|
955
|
+
//#endregion
|
|
956
|
+
//#region src/oxlint-jev/bridge.ts
|
|
957
|
+
/**
|
|
958
|
+
* A result in which nothing was answered, for the failures that happen outside
|
|
959
|
+
* the subprocess's own reporting — it could not start, it died, or what it
|
|
960
|
+
* printed was not a result.
|
|
961
|
+
*/
|
|
962
|
+
const allUnavailable = (files, reason) => ({
|
|
963
|
+
answersByMatchKey: {},
|
|
964
|
+
completion: "unavailable",
|
|
965
|
+
elapsedMs: 0,
|
|
966
|
+
models: [],
|
|
967
|
+
requests: [],
|
|
968
|
+
unavailableByMatchKey: Object.fromEntries(files.flatMap((file) => file.matches.map((match) => [match.key, reason])))
|
|
969
|
+
});
|
|
970
|
+
/**
|
|
971
|
+
* `worker.ts` in a source checkout, `worker.js` in the published package. The
|
|
972
|
+
* extension is read from this module's own path, so the source layout and the
|
|
973
|
+
* built one stay in step without a build-time constant. The build keeps the
|
|
974
|
+
* two files side by side in `dist/oxlint-jev/` for the same reason.
|
|
975
|
+
*/
|
|
976
|
+
const workerPath = () => path.join(import.meta.dirname, `worker${path.extname(import.meta.filename)}`);
|
|
977
|
+
/** Room for a whole file's requests and answers. The 1 MiB default is not. */
|
|
978
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
979
|
+
/**
|
|
980
|
+
* Runs one file's requests in a subprocess and returns what happened.
|
|
981
|
+
*
|
|
982
|
+
* Nothing the subprocess wrote is read into a reason. Its stderr can carry a
|
|
983
|
+
* stack that quotes the request, and the request is repository source
|
|
984
|
+
* (`src/jev/client.ts` holds the same rule for upstream bodies), so a failure
|
|
985
|
+
* this function cannot parse becomes one fixed sentence plus the exit status.
|
|
986
|
+
*/
|
|
987
|
+
const spawnJevBridge = ({ cacheDirectory, files }) => {
|
|
988
|
+
const child = spawnSync(process.execPath, [workerPath()], {
|
|
989
|
+
encoding: "utf-8",
|
|
990
|
+
input: JSON.stringify({
|
|
991
|
+
cacheDirectory,
|
|
992
|
+
files
|
|
993
|
+
}),
|
|
994
|
+
maxBuffer: MAX_OUTPUT_BYTES
|
|
995
|
+
});
|
|
996
|
+
if (child.error !== void 0 || child.status !== 0) {
|
|
997
|
+
const code = child.error?.code;
|
|
998
|
+
return allUnavailable(files, `the Jev worker exited with status ${String(child.status)}, signal ${String(child.signal)}${code === void 0 ? "" : `, spawn error ${code}`}. Its output is not reported: it can quote the request, which is repository source.`);
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
return JSON.parse(child.stdout);
|
|
1002
|
+
} catch {
|
|
1003
|
+
return allUnavailable(files, "the Jev worker did not print a result. Its output is not reported: it can quote the request, which is repository source.");
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
//#endregion
|
|
1007
|
+
export { resolveJevGateway as a, UNKNOWN_MODEL as c, buildJevRequests as i, noul as l, spawnJevBridge as n, defaultJevCacheDirectory as o, runJevFiles as r, openJevCache as s, allUnavailable as t };
|