@inflexa-ai/harness 0.35.4 → 0.35.6
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/dist/agents/conversation-agent.js +10 -2
- package/dist/lib/metrics.d.ts +57 -6
- package/dist/lib/metrics.js +105 -8
- package/dist/prompts/briefing.js +6 -3
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +16 -8
- package/dist/prompts/planner.d.ts +4 -0
- package/dist/prompts/planner.js +22 -16
- package/dist/sandbox/create-sandbox.js +23 -1
- package/dist/sandbox/exec-outcome.d.ts +45 -0
- package/dist/sandbox/exec-outcome.js +65 -0
- package/dist/sandbox/k8s-client.d.ts +10 -0
- package/dist/sandbox/k8s-client.js +40 -5
- package/dist/sandbox/liveness.d.ts +3 -1
- package/dist/sandbox/types.d.ts +24 -0
- package/dist/sandbox/types.js +24 -0
- package/dist/tools/ad-hoc-router.d.ts +23 -0
- package/dist/tools/ad-hoc-router.js +93 -5
- package/dist/tools/bio/alphafold-prediction.d.ts +19 -0
- package/dist/tools/bio/alphafold-prediction.js +40 -0
- package/dist/tools/bio/index.d.ts +2 -0
- package/dist/tools/bio/index.js +3 -0
- package/dist/tools/bio/search-protein.d.ts +20 -0
- package/dist/tools/bio/search-protein.js +61 -0
- package/dist/tools/execute-analysis.d.ts +2 -1
- package/dist/tools/execute-analysis.js +66 -5
- package/dist/tools/lib/alphafold-client.d.ts +55 -0
- package/dist/tools/lib/alphafold-client.js +72 -0
- package/dist/tools/lib/api-utils.d.ts +25 -0
- package/dist/tools/lib/api-utils.js +45 -3
- package/dist/tools/lib/uniprot-client.d.ts +131 -0
- package/dist/tools/lib/uniprot-client.js +190 -0
- package/dist/tools/research/literature-reviewer.js +2 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +25 -0
- package/dist/tools/sandbox/list-available-packages.js +51 -29
- package/dist/workflows/sandbox-step.js +10 -1
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import { RunDedupCollisionError, insertRun, loadPlan, queryActiveRun, reserveRun
|
|
|
17
17
|
import { routeAdHocRequest } from "./ad-hoc-router.js";
|
|
18
18
|
import { adHocPlanId, adHocRunId } from "./analysis-invocation.js";
|
|
19
19
|
import { defineTool } from "./define-tool.js";
|
|
20
|
+
import { queryPackages, readInventorySections } from "./sandbox/list-available-packages.js";
|
|
20
21
|
const planIdSchema = z.string().regex(/^pln-[a-f0-9]{8}$/, "planId must be a pln-<8hex> value");
|
|
21
22
|
const inputSchema = z
|
|
22
23
|
.object({
|
|
@@ -86,10 +87,15 @@ export class PlanPackagesMissingError extends Error {
|
|
|
86
87
|
* run reserves anything. The linked set is the union of the queries of each
|
|
87
88
|
* step. A pool miss and a collision refuse the launch with the spellings.
|
|
88
89
|
* Without a bound seam, the pass returns at once.
|
|
90
|
+
*
|
|
91
|
+
* @returns How many queries reached the seam. Zero says that nothing was
|
|
92
|
+
* checked — an unbound seam, or a plan whose steps declare no package — which
|
|
93
|
+
* is what the launch record must carry to tell a checked launch from an
|
|
94
|
+
* unchecked one.
|
|
89
95
|
*/
|
|
90
96
|
async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
|
|
91
97
|
if (!extendAnalysisFarm)
|
|
92
|
-
return;
|
|
98
|
+
return 0;
|
|
93
99
|
// The union dedupes EQUAL queries only: two entries are one ask when their
|
|
94
100
|
// spelling, their track, and their version are equal. A bare entry beside a
|
|
95
101
|
// qualified entry of one spelling stays two asks, because the bare one
|
|
@@ -112,7 +118,7 @@ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
|
|
|
112
118
|
}
|
|
113
119
|
const queries = [...union.values()];
|
|
114
120
|
if (queries.length === 0)
|
|
115
|
-
return;
|
|
121
|
+
return 0;
|
|
116
122
|
let outcomes;
|
|
117
123
|
try {
|
|
118
124
|
outcomes = await extendAnalysisFarm(analysisId, queries);
|
|
@@ -154,6 +160,7 @@ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
|
|
|
154
160
|
if (missing.length > 0 || collisions.length > 0) {
|
|
155
161
|
throw new PlanPackagesMissingError(missing, collisions);
|
|
156
162
|
}
|
|
163
|
+
return queries.length;
|
|
157
164
|
}
|
|
158
165
|
function planSummary(plan) {
|
|
159
166
|
return plan.title?.trim() || plan.analytical_narrative.trim().slice(0, 280);
|
|
@@ -192,8 +199,32 @@ function adHocTitle(request) {
|
|
|
192
199
|
const compact = request.trim().replace(/\s+/g, " ");
|
|
193
200
|
return compact.length <= 80 ? compact : `${compact.slice(0, 77)}…`;
|
|
194
201
|
}
|
|
202
|
+
/** How a dropped entry reads to the step agent, in the terms of its own reason. */
|
|
203
|
+
const DROP_REASON_TEXT = {
|
|
204
|
+
unparsable: "does not parse",
|
|
205
|
+
absent: "the pool does not hold it",
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* What the step agent must know about the packages that it did not choose.
|
|
209
|
+
* The routing guessed them, and no person reviewed that guess, thus a drop and
|
|
210
|
+
* an empty list are facts the agent needs before its first import.
|
|
211
|
+
*/
|
|
212
|
+
function adHocCaveats(route) {
|
|
213
|
+
const caveats = [];
|
|
214
|
+
if (route.fallbackClass)
|
|
215
|
+
caveats.push(`Automatic routing fallback: ${route.fallbackClass}`);
|
|
216
|
+
if (route.droppedPackages.length > 0) {
|
|
217
|
+
const drops = route.droppedPackages.map((drop) => `\`${drop.entry}\` (${DROP_REASON_TEXT[drop.reason]})`).join(", ");
|
|
218
|
+
caveats.push(`The routing dropped these package entries: ${drops}.`);
|
|
219
|
+
}
|
|
220
|
+
if (route.packages.length === 0) {
|
|
221
|
+
caveats.push("This step declares no packages, thus the pre-launch link pass linked nothing. Confirm that each library you intend to use imports before you rely on it.");
|
|
222
|
+
}
|
|
223
|
+
return caveats;
|
|
224
|
+
}
|
|
195
225
|
export function buildAdHocPlan(request, route, createdAt = new Date().toISOString()) {
|
|
196
226
|
const meta = SANDBOX_AGENT_META[route.agentId];
|
|
227
|
+
const caveats = adHocCaveats(route);
|
|
197
228
|
const plan = {
|
|
198
229
|
title: adHocTitle(request),
|
|
199
230
|
analytical_narrative: `Execute one targeted analysis step for the explicit request: ${request}`,
|
|
@@ -215,7 +246,8 @@ export function buildAdHocPlan(request, route, createdAt = new Date().toISOStrin
|
|
|
215
246
|
"Persist machine-readable result file(s), even when the answer is a single scalar.",
|
|
216
247
|
"Provide a direct result summary grounded in the persisted outputs.",
|
|
217
248
|
],
|
|
218
|
-
caveats:
|
|
249
|
+
caveats: caveats.length > 0 ? caveats : undefined,
|
|
250
|
+
packages: [...route.packages],
|
|
219
251
|
depends_on: [],
|
|
220
252
|
status: "pending",
|
|
221
253
|
resources: route.resources,
|
|
@@ -226,16 +258,45 @@ export function buildAdHocPlan(request, route, createdAt = new Date().toISOStrin
|
|
|
226
258
|
};
|
|
227
259
|
return AnalysisPlanSchema.parse(plan);
|
|
228
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* The targeted presence check that the router runs over the names it emits,
|
|
263
|
+
* against the same inventory that `list_available_packages` reads.
|
|
264
|
+
*
|
|
265
|
+
* It binds only when the embedder gives an inventory source. The unbound
|
|
266
|
+
* defaults name a container mountpoint, and a host that mounts nothing there
|
|
267
|
+
* would pay a file read on every ad hoc launch to learn nothing — while the
|
|
268
|
+
* router, with no resolution, already keeps every entry that parses.
|
|
269
|
+
*/
|
|
270
|
+
function adHocPackageResolver(deps) {
|
|
271
|
+
if (!deps.farmLockFile && !deps.imagePackagesFile && !deps.readPoolInventory)
|
|
272
|
+
return undefined;
|
|
273
|
+
const inventory = {
|
|
274
|
+
...(deps.farmLockFile === undefined ? {} : { farmLockFile: deps.farmLockFile }),
|
|
275
|
+
...(deps.imagePackagesFile === undefined ? {} : { imagePackagesFile: deps.imagePackagesFile }),
|
|
276
|
+
...(deps.readPoolInventory === undefined ? {} : { readPoolInventory: deps.readPoolInventory }),
|
|
277
|
+
};
|
|
278
|
+
return async (names) => {
|
|
279
|
+
const read = await readInventorySections(inventory);
|
|
280
|
+
// An inventory that cannot answer must not drop a name: the router
|
|
281
|
+
// keeps every parsed entry, and the link pass judges at launch.
|
|
282
|
+
if (read.kind === "unavailable")
|
|
283
|
+
return null;
|
|
284
|
+
const answer = queryPackages(read.sections, { names });
|
|
285
|
+
return "checked" in answer ? answer.checked : null;
|
|
286
|
+
};
|
|
287
|
+
}
|
|
229
288
|
async function persistedAdHocPlan(deps, args) {
|
|
230
289
|
const existing = unwrapOrThrow(await loadPlan(deps.pool, args.planId, { analysisId: args.analysisId }));
|
|
231
290
|
if (existing)
|
|
232
291
|
return validateStoredPlan(existing, args.planId);
|
|
292
|
+
const resolvePackages = adHocPackageResolver(deps);
|
|
233
293
|
const route = await routeAdHocRequest({
|
|
234
294
|
provider: deps.utilityProvider,
|
|
235
295
|
model: deps.utilityModel,
|
|
236
296
|
pool: deps.pool,
|
|
237
297
|
resourcePolicy: deps.resourcePolicy,
|
|
238
298
|
logger: deps.logger,
|
|
299
|
+
...(resolvePackages ? { resolvePackages } : {}),
|
|
239
300
|
}, { analysisId: args.analysisId, request: args.request, session: args.ctx.session, signal: args.ctx.signal });
|
|
240
301
|
const candidate = buildAdHocPlan(args.request, route);
|
|
241
302
|
unwrapOrThrow(await upsertPlan(deps.pool, { planId: args.planId, analysisId: args.analysisId, plan: candidate }));
|
|
@@ -286,7 +347,7 @@ export function createExecuteAnalysisTool(deps) {
|
|
|
286
347
|
}
|
|
287
348
|
// The link pass comes before the run reserves anything, thus a pool
|
|
288
349
|
// miss leaves no run row and no revoked authorization behind.
|
|
289
|
-
await linkPlanPackages(deps.extendAnalysisFarm, analysisId, plan);
|
|
350
|
+
const packageQueries = await linkPlanPackages(deps.extendAnalysisFarm, analysisId, plan);
|
|
290
351
|
const runId = input.mode === "plan" ? randomUUID() : adHocRunId(analysisId, ctx.invocationId);
|
|
291
352
|
if (input.mode === "plan") {
|
|
292
353
|
try {
|
|
@@ -351,7 +412,7 @@ export function createExecuteAnalysisTool(deps) {
|
|
|
351
412
|
await updateRunStatus(deps.pool, runId, "failed", "workflow start failed").match(() => { }, () => { });
|
|
352
413
|
throw error;
|
|
353
414
|
}
|
|
354
|
-
logger.info("analysis launched", { analysisId, runId, planId, mode: input.mode });
|
|
415
|
+
logger.info("analysis launched", { analysisId, runId, planId, mode: input.mode, packageQueries });
|
|
355
416
|
await emitRunCard(runId);
|
|
356
417
|
return ok({ runId, status: "in_progress" });
|
|
357
418
|
},
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure async client functions for the AlphaFold Protein Structure Database
|
|
3
|
+
* (AlphaFold DB) prediction API. Keyless and public: one unauthenticated GET,
|
|
4
|
+
* keyed by a UniProt accession.
|
|
5
|
+
*
|
|
6
|
+
* Absence policy: AlphaFold DB omits the key of an absent value — the
|
|
7
|
+
* AlphaMissense annotation URLs (`amAnnotationsUrl` and its two genome-build
|
|
8
|
+
* siblings) ride on the AlphaMissense proteome-wide predictions, which cover
|
|
9
|
+
* the human proteome only. A canonical accession of a different organism
|
|
10
|
+
* carries no such URL, and neither does an isoform entry of any organism —
|
|
11
|
+
* thus a maybe-absent field carries `.optional()`, not `.nullable()`.
|
|
12
|
+
*
|
|
13
|
+
* Not-found semantics: AlphaFold splits absence over two status codes. An
|
|
14
|
+
* identifier that does not parse as a UniProt accession or an AlphaFold DB id
|
|
15
|
+
* gives 400 with an `Invalid identifier format` body. A well-formed accession
|
|
16
|
+
* that AlphaFold holds no model for gives 404 with an empty object. Both are
|
|
17
|
+
* the same outcome for a caller, and `isUnexpectedApiError` classifies each
|
|
18
|
+
* `http_status` in the 4xx range as expected, thus one branch answers both.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
/**
|
|
22
|
+
* One entry of the AlphaFold DB prediction array. A query by a canonical
|
|
23
|
+
* accession can answer with more than one entry — one per UniProt isoform —
|
|
24
|
+
* thus the schema is `z.array(...)` and the client selects the entry that
|
|
25
|
+
* matches the queried accession.
|
|
26
|
+
*/
|
|
27
|
+
export declare const AlphaFoldPredictionSchema: z.ZodObject<{
|
|
28
|
+
uniprotAccession: z.ZodString;
|
|
29
|
+
uniprotDescription: z.ZodString;
|
|
30
|
+
latestVersion: z.ZodNumber;
|
|
31
|
+
modelCreatedDate: z.ZodString;
|
|
32
|
+
globalMetricValue: z.ZodNumber;
|
|
33
|
+
fractionPlddtVeryLow: z.ZodNumber;
|
|
34
|
+
fractionPlddtLow: z.ZodNumber;
|
|
35
|
+
fractionPlddtConfident: z.ZodNumber;
|
|
36
|
+
fractionPlddtVeryHigh: z.ZodNumber;
|
|
37
|
+
pdbUrl: z.ZodURL;
|
|
38
|
+
cifUrl: z.ZodURL;
|
|
39
|
+
paeImageUrl: z.ZodURL;
|
|
40
|
+
plddtDocUrl: z.ZodURL;
|
|
41
|
+
paeDocUrl: z.ZodURL;
|
|
42
|
+
amAnnotationsUrl: z.ZodOptional<z.ZodURL>;
|
|
43
|
+
}, z.core.$strip>;
|
|
44
|
+
export type AlphaFoldPrediction = z.infer<typeof AlphaFoldPredictionSchema>;
|
|
45
|
+
/**
|
|
46
|
+
* Fetch the AlphaFold structure prediction for one UniProt accession.
|
|
47
|
+
*
|
|
48
|
+
* Gives `null` when AlphaFold holds no model for the accession, and `null` for
|
|
49
|
+
* an identifier it cannot parse. When the array holds more than one entry —
|
|
50
|
+
* the isoforms of one canonical accession — the entry whose own
|
|
51
|
+
* `uniprotAccession` matches the queried accession wins. A query that names no
|
|
52
|
+
* exact isoform falls back to the first entry, which AlphaFold orders
|
|
53
|
+
* canonical-first.
|
|
54
|
+
*/
|
|
55
|
+
export declare function fetchAlphaFoldPrediction(uniprotAccession: string): Promise<AlphaFoldPrediction | null>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure async client functions for the AlphaFold Protein Structure Database
|
|
3
|
+
* (AlphaFold DB) prediction API. Keyless and public: one unauthenticated GET,
|
|
4
|
+
* keyed by a UniProt accession.
|
|
5
|
+
*
|
|
6
|
+
* Absence policy: AlphaFold DB omits the key of an absent value — the
|
|
7
|
+
* AlphaMissense annotation URLs (`amAnnotationsUrl` and its two genome-build
|
|
8
|
+
* siblings) ride on the AlphaMissense proteome-wide predictions, which cover
|
|
9
|
+
* the human proteome only. A canonical accession of a different organism
|
|
10
|
+
* carries no such URL, and neither does an isoform entry of any organism —
|
|
11
|
+
* thus a maybe-absent field carries `.optional()`, not `.nullable()`.
|
|
12
|
+
*
|
|
13
|
+
* Not-found semantics: AlphaFold splits absence over two status codes. An
|
|
14
|
+
* identifier that does not parse as a UniProt accession or an AlphaFold DB id
|
|
15
|
+
* gives 400 with an `Invalid identifier format` body. A well-formed accession
|
|
16
|
+
* that AlphaFold holds no model for gives 404 with an empty object. Both are
|
|
17
|
+
* the same outcome for a caller, and `isUnexpectedApiError` classifies each
|
|
18
|
+
* `http_status` in the 4xx range as expected, thus one branch answers both.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { apiFetchValidated, describeApiError, isUnexpectedApiError } from "./api-utils.js";
|
|
22
|
+
const ALPHAFOLD_BASE = "https://alphafold.ebi.ac.uk/api/prediction";
|
|
23
|
+
/**
|
|
24
|
+
* One entry of the AlphaFold DB prediction array. A query by a canonical
|
|
25
|
+
* accession can answer with more than one entry — one per UniProt isoform —
|
|
26
|
+
* thus the schema is `z.array(...)` and the client selects the entry that
|
|
27
|
+
* matches the queried accession.
|
|
28
|
+
*/
|
|
29
|
+
export const AlphaFoldPredictionSchema = z.object({
|
|
30
|
+
uniprotAccession: z.string(),
|
|
31
|
+
uniprotDescription: z.string(),
|
|
32
|
+
latestVersion: z.number(),
|
|
33
|
+
modelCreatedDate: z.string(),
|
|
34
|
+
globalMetricValue: z.number(),
|
|
35
|
+
fractionPlddtVeryLow: z.number(),
|
|
36
|
+
fractionPlddtLow: z.number(),
|
|
37
|
+
fractionPlddtConfident: z.number(),
|
|
38
|
+
fractionPlddtVeryHigh: z.number(),
|
|
39
|
+
pdbUrl: z.url(),
|
|
40
|
+
cifUrl: z.url(),
|
|
41
|
+
paeImageUrl: z.url(),
|
|
42
|
+
// The per-residue confidence document. `globalMetricValue` and the
|
|
43
|
+
// `fractionPlddt*` fractions describe the whole chain, thus this URL is the
|
|
44
|
+
// only field that tells a caller *which* residues are disordered.
|
|
45
|
+
plddtDocUrl: z.url(),
|
|
46
|
+
paeDocUrl: z.url(),
|
|
47
|
+
amAnnotationsUrl: z.url().optional(),
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* Fetch the AlphaFold structure prediction for one UniProt accession.
|
|
51
|
+
*
|
|
52
|
+
* Gives `null` when AlphaFold holds no model for the accession, and `null` for
|
|
53
|
+
* an identifier it cannot parse. When the array holds more than one entry —
|
|
54
|
+
* the isoforms of one canonical accession — the entry whose own
|
|
55
|
+
* `uniprotAccession` matches the queried accession wins. A query that names no
|
|
56
|
+
* exact isoform falls back to the first entry, which AlphaFold orders
|
|
57
|
+
* canonical-first.
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchAlphaFoldPrediction(uniprotAccession) {
|
|
60
|
+
const accession = uniprotAccession.trim();
|
|
61
|
+
const res = await apiFetchValidated(`${ALPHAFOLD_BASE}/${encodeURIComponent(accession)}`, z.array(AlphaFoldPredictionSchema));
|
|
62
|
+
if (res.isErr()) {
|
|
63
|
+
if (isUnexpectedApiError(res.error))
|
|
64
|
+
throw new Error(describeApiError(res.error));
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const entries = res.value;
|
|
68
|
+
if (entries.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const exact = entries.find((entry) => entry.uniprotAccession.toUpperCase() === accession.toUpperCase());
|
|
71
|
+
return exact ?? entries[0];
|
|
72
|
+
}
|
|
@@ -16,6 +16,19 @@ export interface ApiFetchOptions {
|
|
|
16
16
|
body?: string;
|
|
17
17
|
maxRetries?: number;
|
|
18
18
|
retryDelayMs?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Ceiling on any single retry wait, the value a server asks for through
|
|
21
|
+
* `Retry-After` included. The default is finite because this wait is not
|
|
22
|
+
* cancelable and these calls run inside a DBOS step, thus an unbounded
|
|
23
|
+
* `Retry-After: 3600` would park that step for an hour.
|
|
24
|
+
*/
|
|
25
|
+
maxRetryDelayMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Seam for the wait between two attempts. A test binds a function that
|
|
28
|
+
* records the wait and returns at once, thus it asserts the computed delay
|
|
29
|
+
* without real time.
|
|
30
|
+
*/
|
|
31
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
32
|
timeoutMs?: number;
|
|
20
33
|
parseAs?: "json" | "text";
|
|
21
34
|
}
|
|
@@ -40,6 +53,14 @@ export type ApiError = {
|
|
|
40
53
|
* `err` carries the structured failure; a non-ok HTTP status is reported as
|
|
41
54
|
* `http_status` so callers can branch on a concrete code (a 404 is usually an
|
|
42
55
|
* expected "not found" → a data variant; see `isUnexpectedApiError`).
|
|
56
|
+
*
|
|
57
|
+
* A retryable status that outlives the retries is the exception: it resolves to
|
|
58
|
+
* `exhausted`, whose `lastError` is `HTTP <status>`. A throttled or a restarting
|
|
59
|
+
* upstream is not the answer "the resource is not there", thus it must not reach
|
|
60
|
+
* a caller as a 4xx that `isUnexpectedApiError` reads as an expected absence.
|
|
61
|
+
*
|
|
62
|
+
* Between two attempts the wait obeys `Retry-After` when the response carries it,
|
|
63
|
+
* and every wait stays at or below `maxRetryDelayMs`.
|
|
43
64
|
*/
|
|
44
65
|
export declare function apiFetch<T = unknown>(url: string, options?: ApiFetchOptions): ResultAsync<T, ApiError>;
|
|
45
66
|
/**
|
|
@@ -72,6 +93,10 @@ export declare function describeApiError(e: ApiError): string;
|
|
|
72
93
|
* timeout, retry exhaustion, a transport failure, or a schema mismatch
|
|
73
94
|
* (`invalid_response`) — is unexpected and the caller should surface it by
|
|
74
95
|
* throwing (or returning `err`).
|
|
96
|
+
*
|
|
97
|
+
* A retryable status that outlived the retries never arrives as `http_status`.
|
|
98
|
+
* `apiFetch` gives it as `exhausted`, thus a spent 429 reads as unexpected here
|
|
99
|
+
* and no call site turns a throttle into an absence.
|
|
75
100
|
*/
|
|
76
101
|
export declare function isUnexpectedApiError(e: ApiError): boolean;
|
|
77
102
|
/**
|
|
@@ -20,6 +20,14 @@ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
|
|
|
20
20
|
* `err` carries the structured failure; a non-ok HTTP status is reported as
|
|
21
21
|
* `http_status` so callers can branch on a concrete code (a 404 is usually an
|
|
22
22
|
* expected "not found" → a data variant; see `isUnexpectedApiError`).
|
|
23
|
+
*
|
|
24
|
+
* A retryable status that outlives the retries is the exception: it resolves to
|
|
25
|
+
* `exhausted`, whose `lastError` is `HTTP <status>`. A throttled or a restarting
|
|
26
|
+
* upstream is not the answer "the resource is not there", thus it must not reach
|
|
27
|
+
* a caller as a 4xx that `isUnexpectedApiError` reads as an expected absence.
|
|
28
|
+
*
|
|
29
|
+
* Between two attempts the wait obeys `Retry-After` when the response carries it,
|
|
30
|
+
* and every wait stays at or below `maxRetryDelayMs`.
|
|
23
31
|
*/
|
|
24
32
|
export function apiFetch(url, options = {}) {
|
|
25
33
|
return new ResultAsync(runFetch(url, options));
|
|
@@ -59,8 +67,31 @@ function summarizeZodIssues(error) {
|
|
|
59
67
|
const remaining = error.issues.length - issues.length;
|
|
60
68
|
return remaining > 0 ? `${issues.join("; ")} (+${remaining} more)` : issues.join("; ");
|
|
61
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The wait that a server asks for through `Retry-After`, in milliseconds.
|
|
72
|
+
*
|
|
73
|
+
* The header carries a count of seconds or an HTTP date, and a date that already
|
|
74
|
+
* passed clamps to zero. Any other value — a negative count, text that is neither
|
|
75
|
+
* form — counts as absent, thus the backoff governs. The literature HTTP layer
|
|
76
|
+
* parses the same header (`literature/sources/http.ts`), and the two copies stay
|
|
77
|
+
* separate because that module carries its own schedule, signal, and result type.
|
|
78
|
+
*/
|
|
79
|
+
function retryAfterMilliseconds(res) {
|
|
80
|
+
const value = res.headers.get("retry-after")?.trim();
|
|
81
|
+
if (!value)
|
|
82
|
+
return undefined;
|
|
83
|
+
const seconds = Number(value);
|
|
84
|
+
// A numeric value settles the header, and a negative count is absent rather
|
|
85
|
+
// than a date. `Date.parse` is lenient enough to read "-5" as a calendar date
|
|
86
|
+
// in the past, which would clamp to a wait of zero and retry at once against
|
|
87
|
+
// an upstream that is already throttling.
|
|
88
|
+
if (Number.isFinite(seconds))
|
|
89
|
+
return seconds >= 0 ? seconds * 1000 : undefined;
|
|
90
|
+
const date = Date.parse(value);
|
|
91
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
92
|
+
}
|
|
62
93
|
async function runFetch(url, options) {
|
|
63
|
-
const { method = "GET", headers = {}, body, maxRetries = 3, retryDelayMs = 1000, timeoutMs = 90_000, parseAs = "json" } = options;
|
|
94
|
+
const { method = "GET", headers = {}, body, maxRetries = 3, retryDelayMs = 1000, maxRetryDelayMs = 30_000, sleep: wait = sleep, timeoutMs = 90_000, parseAs = "json", } = options;
|
|
64
95
|
let lastError = "";
|
|
65
96
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
66
97
|
try {
|
|
@@ -71,10 +102,17 @@ async function runFetch(url, options) {
|
|
|
71
102
|
signal: AbortSignal.timeout(timeoutMs),
|
|
72
103
|
});
|
|
73
104
|
if (RETRYABLE_STATUSES.has(res.status) && attempt < maxRetries) {
|
|
74
|
-
await sleep(retryDelayMs * 2 ** attempt);
|
|
75
105
|
lastError = `HTTP ${res.status}`;
|
|
106
|
+
await wait(Math.min(maxRetryDelayMs, retryAfterMilliseconds(res) ?? retryDelayMs * 2 ** attempt));
|
|
76
107
|
continue;
|
|
77
108
|
}
|
|
109
|
+
// The branch above consumed every attempt that still has a retry, thus a
|
|
110
|
+
// retryable status here has spent them all. The status comes from this
|
|
111
|
+
// response and not from `lastError`, because an earlier attempt that failed
|
|
112
|
+
// with a connection error would otherwise name that instead of the throttle.
|
|
113
|
+
if (RETRYABLE_STATUSES.has(res.status)) {
|
|
114
|
+
return err({ type: "exhausted", attempts: attempt + 1, lastError: `HTTP ${res.status}` });
|
|
115
|
+
}
|
|
78
116
|
if (!res.ok) {
|
|
79
117
|
const text = await res.text().catch(() => "");
|
|
80
118
|
return err({ type: "http_status", status: res.status, body: text.trim() });
|
|
@@ -88,7 +126,7 @@ async function runFetch(url, options) {
|
|
|
88
126
|
}
|
|
89
127
|
lastError = e instanceof Error ? e.message : String(e);
|
|
90
128
|
if (attempt < maxRetries) {
|
|
91
|
-
await
|
|
129
|
+
await wait(Math.min(maxRetryDelayMs, retryDelayMs * 2 ** attempt));
|
|
92
130
|
continue;
|
|
93
131
|
}
|
|
94
132
|
}
|
|
@@ -117,6 +155,10 @@ export function describeApiError(e) {
|
|
|
117
155
|
* timeout, retry exhaustion, a transport failure, or a schema mismatch
|
|
118
156
|
* (`invalid_response`) — is unexpected and the caller should surface it by
|
|
119
157
|
* throwing (or returning `err`).
|
|
158
|
+
*
|
|
159
|
+
* A retryable status that outlived the retries never arrives as `http_status`.
|
|
160
|
+
* `apiFetch` gives it as `exhausted`, thus a spent 429 reads as unexpected here
|
|
161
|
+
* and no call site turns a throttle into an absence.
|
|
120
162
|
*/
|
|
121
163
|
export function isUnexpectedApiError(e) {
|
|
122
164
|
return !(e.type === "http_status" && e.status >= 400 && e.status < 500);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* and the API omits the key of an absent value. Thus a maybe-absent field
|
|
14
14
|
* carries `.optional()`, not `.nullable()`.
|
|
15
15
|
*/
|
|
16
|
+
import { z } from "zod";
|
|
16
17
|
export interface UniProtRecord {
|
|
17
18
|
primaryAccession: string;
|
|
18
19
|
uniProtkbId: string | null;
|
|
@@ -30,3 +31,133 @@ export declare function getUniProtRecord(accession: string): Promise<UniProtReco
|
|
|
30
31
|
export declare function getChemblIdsByUniProt(accession: string): Promise<string[]>;
|
|
31
32
|
/** All Reactome pathway ids cross-referenced from a UniProt accession. */
|
|
32
33
|
export declare function getReactomePathwaysByUniProt(accession: string): Promise<string[]>;
|
|
34
|
+
/** One protein of a UniProtKB search. */
|
|
35
|
+
export interface UniProtProtein {
|
|
36
|
+
accession: string;
|
|
37
|
+
/** The UniProtKB ID, for example `BRCA1_HUMAN`. */
|
|
38
|
+
uniProtkbId: string | null;
|
|
39
|
+
proteinName: string | null;
|
|
40
|
+
geneNames: string[];
|
|
41
|
+
sequenceLength: number | null;
|
|
42
|
+
/** The curated FUNCTION comment, trimmed to a readable length. */
|
|
43
|
+
function: string | null;
|
|
44
|
+
/** The distinct curated subcellular locations, in the order UniProt lists them. */
|
|
45
|
+
subcellularLocations: string[];
|
|
46
|
+
/** True for a Swiss-Prot entry, false for a TrEMBL entry. */
|
|
47
|
+
reviewed: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One result row of `uniprotkb/search`, with the same all-optional discipline as
|
|
51
|
+
* `UniProtRecordSchema`: UniProt omits the key of an absent value, thus every
|
|
52
|
+
* field carries `.optional()` and none carries `.nullable()`.
|
|
53
|
+
*
|
|
54
|
+
* A TrEMBL row can carry `submissionNames` in place of `recommendedName`
|
|
55
|
+
* (`X5D778` is one), thus the name reader falls back rather than reporting the
|
|
56
|
+
* protein as unnamed.
|
|
57
|
+
*/
|
|
58
|
+
export declare const UniProtSearchResultSchema: z.ZodObject<{
|
|
59
|
+
primaryAccession: z.ZodOptional<z.ZodString>;
|
|
60
|
+
uniProtkbId: z.ZodOptional<z.ZodString>;
|
|
61
|
+
entryType: z.ZodOptional<z.ZodString>;
|
|
62
|
+
proteinDescription: z.ZodOptional<z.ZodObject<{
|
|
63
|
+
recommendedName: z.ZodOptional<z.ZodObject<{
|
|
64
|
+
fullName: z.ZodOptional<z.ZodObject<{
|
|
65
|
+
value: z.ZodOptional<z.ZodString>;
|
|
66
|
+
}, z.core.$strip>>;
|
|
67
|
+
}, z.core.$strip>>;
|
|
68
|
+
submissionNames: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
69
|
+
fullName: z.ZodOptional<z.ZodObject<{
|
|
70
|
+
value: z.ZodOptional<z.ZodString>;
|
|
71
|
+
}, z.core.$strip>>;
|
|
72
|
+
}, z.core.$strip>>>;
|
|
73
|
+
}, z.core.$strip>>;
|
|
74
|
+
genes: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
75
|
+
geneName: z.ZodOptional<z.ZodObject<{
|
|
76
|
+
value: z.ZodOptional<z.ZodString>;
|
|
77
|
+
}, z.core.$strip>>;
|
|
78
|
+
}, z.core.$strip>>>;
|
|
79
|
+
sequence: z.ZodOptional<z.ZodObject<{
|
|
80
|
+
length: z.ZodOptional<z.ZodNumber>;
|
|
81
|
+
}, z.core.$strip>>;
|
|
82
|
+
comments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
83
|
+
commentType: z.ZodOptional<z.ZodString>;
|
|
84
|
+
texts: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
85
|
+
value: z.ZodOptional<z.ZodString>;
|
|
86
|
+
}, z.core.$strip>>>;
|
|
87
|
+
subcellularLocations: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
88
|
+
location: z.ZodOptional<z.ZodObject<{
|
|
89
|
+
value: z.ZodOptional<z.ZodString>;
|
|
90
|
+
}, z.core.$strip>>;
|
|
91
|
+
}, z.core.$strip>>>;
|
|
92
|
+
}, z.core.$strip>>>;
|
|
93
|
+
}, z.core.$strip>;
|
|
94
|
+
/** The search envelope. It is exported so that the golden-fixture table drives it. */
|
|
95
|
+
export declare const UniProtSearchResponseSchema: z.ZodObject<{
|
|
96
|
+
results: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
97
|
+
primaryAccession: z.ZodOptional<z.ZodString>;
|
|
98
|
+
uniProtkbId: z.ZodOptional<z.ZodString>;
|
|
99
|
+
entryType: z.ZodOptional<z.ZodString>;
|
|
100
|
+
proteinDescription: z.ZodOptional<z.ZodObject<{
|
|
101
|
+
recommendedName: z.ZodOptional<z.ZodObject<{
|
|
102
|
+
fullName: z.ZodOptional<z.ZodObject<{
|
|
103
|
+
value: z.ZodOptional<z.ZodString>;
|
|
104
|
+
}, z.core.$strip>>;
|
|
105
|
+
}, z.core.$strip>>;
|
|
106
|
+
submissionNames: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
107
|
+
fullName: z.ZodOptional<z.ZodObject<{
|
|
108
|
+
value: z.ZodOptional<z.ZodString>;
|
|
109
|
+
}, z.core.$strip>>;
|
|
110
|
+
}, z.core.$strip>>>;
|
|
111
|
+
}, z.core.$strip>>;
|
|
112
|
+
genes: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
113
|
+
geneName: z.ZodOptional<z.ZodObject<{
|
|
114
|
+
value: z.ZodOptional<z.ZodString>;
|
|
115
|
+
}, z.core.$strip>>;
|
|
116
|
+
}, z.core.$strip>>>;
|
|
117
|
+
sequence: z.ZodOptional<z.ZodObject<{
|
|
118
|
+
length: z.ZodOptional<z.ZodNumber>;
|
|
119
|
+
}, z.core.$strip>>;
|
|
120
|
+
comments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
121
|
+
commentType: z.ZodOptional<z.ZodString>;
|
|
122
|
+
texts: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
123
|
+
value: z.ZodOptional<z.ZodString>;
|
|
124
|
+
}, z.core.$strip>>>;
|
|
125
|
+
subcellularLocations: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
126
|
+
location: z.ZodOptional<z.ZodObject<{
|
|
127
|
+
value: z.ZodOptional<z.ZodString>;
|
|
128
|
+
}, z.core.$strip>>;
|
|
129
|
+
}, z.core.$strip>>>;
|
|
130
|
+
}, z.core.$strip>>>;
|
|
131
|
+
}, z.core.$strip>>>;
|
|
132
|
+
}, z.core.$strip>;
|
|
133
|
+
/**
|
|
134
|
+
* Can the query ride in an `accession:` clause?
|
|
135
|
+
*
|
|
136
|
+
* UniProt validates the value of that clause, and it answers HTTP 400 for a
|
|
137
|
+
* value that is not accession-shaped. Thus a plain symbol such as `BRCA1` must
|
|
138
|
+
* never reach one, because the 400 fails the whole request.
|
|
139
|
+
*/
|
|
140
|
+
export declare function isUniProtAccession(query: string): boolean;
|
|
141
|
+
export interface SearchProteinsOptions {
|
|
142
|
+
/** NCBI taxonomy id; omit to search every organism. */
|
|
143
|
+
organismId?: number;
|
|
144
|
+
/** Swiss-Prot only when true (the default), Swiss-Prot and TrEMBL when false. */
|
|
145
|
+
reviewedOnly?: boolean;
|
|
146
|
+
/** Max rows returned. */
|
|
147
|
+
limit?: number;
|
|
148
|
+
}
|
|
149
|
+
export interface SearchProteinsResult {
|
|
150
|
+
proteins: UniProtProtein[];
|
|
151
|
+
/** True when UniProt held at least one row beyond `limit`. */
|
|
152
|
+
hasMore: boolean;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Search UniProtKB for a protein by gene symbol or by accession.
|
|
156
|
+
*
|
|
157
|
+
* An unknown query is HTTP 200 with `{"results":[]}`, never a 404, thus the
|
|
158
|
+
* empty result is an ordinary answer and never an error. The request asks for
|
|
159
|
+
* one row beyond `limit`, because UniProt reports its match count in the
|
|
160
|
+
* `x-total-results` header and `apiFetch` exposes no header. The extra row is
|
|
161
|
+
* what separates a trimmed answer from a complete one.
|
|
162
|
+
*/
|
|
163
|
+
export declare function searchProteins(query: string, opts?: SearchProteinsOptions): Promise<SearchProteinsResult>;
|