@inflexa-ai/harness 0.30.2 → 0.31.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.
Files changed (123) hide show
  1. package/dist/contracts/target-dossier.d.ts +61 -61
  2. package/dist/execution/run-synthesis.js +7 -2
  3. package/dist/lib/zod-issues.d.ts +8 -0
  4. package/dist/lib/zod-issues.js +13 -0
  5. package/dist/literature/sources/semantic-scholar.d.ts +34 -0
  6. package/dist/literature/sources/semantic-scholar.js +15 -4
  7. package/dist/sandbox/cpu-files.d.ts +37 -0
  8. package/dist/sandbox/cpu-files.js +55 -0
  9. package/dist/sandbox/docker-client.d.ts +15 -0
  10. package/dist/sandbox/docker-client.js +100 -11
  11. package/dist/sandbox/k8s-client.d.ts +5 -0
  12. package/dist/sandbox/k8s-client.js +128 -19
  13. package/dist/sandbox/thread-env.d.ts +29 -0
  14. package/dist/sandbox/thread-env.js +49 -0
  15. package/dist/state/data-profile.d.ts +35 -0
  16. package/dist/state/data-profile.js +56 -0
  17. package/dist/state/index.d.ts +1 -1
  18. package/dist/state/index.js +1 -1
  19. package/dist/tasks/data-profile.d.ts +16 -1
  20. package/dist/tasks/data-profile.js +31 -10
  21. package/dist/tools/bio/chembl.js +4 -3
  22. package/dist/tools/bio/drug-gene-interactions.d.ts +2 -14
  23. package/dist/tools/bio/drug-gene-interactions.js +10 -18
  24. package/dist/tools/bio/gene-disease-evidence.d.ts +3 -1
  25. package/dist/tools/bio/gene-disease-evidence.js +12 -9
  26. package/dist/tools/bio/keys.js +1 -0
  27. package/dist/tools/bio/lookup-annotation.d.ts +43 -0
  28. package/dist/tools/bio/lookup-annotation.js +19 -4
  29. package/dist/tools/bio/pubchem.js +5 -3
  30. package/dist/tools/bio/search-clinical-trials.js +5 -4
  31. package/dist/tools/bio/search-geo-datasets.d.ts +39 -9
  32. package/dist/tools/bio/search-geo-datasets.js +38 -20
  33. package/dist/tools/lib/api-utils.d.ts +27 -2
  34. package/dist/tools/lib/api-utils.js +43 -3
  35. package/dist/tools/lib/bgee-client.d.ts +16 -0
  36. package/dist/tools/lib/bgee-client.js +16 -5
  37. package/dist/tools/lib/cbioportal-client.d.ts +20 -0
  38. package/dist/tools/lib/cbioportal-client.js +16 -8
  39. package/dist/tools/lib/chembl-client.d.ts +105 -3
  40. package/dist/tools/lib/chembl-client.js +196 -84
  41. package/dist/tools/lib/clinical-benchmarks-client.d.ts +5 -0
  42. package/dist/tools/lib/clinical-benchmarks-client.js +5 -0
  43. package/dist/tools/lib/clinical-trials-client.d.ts +131 -2
  44. package/dist/tools/lib/clinical-trials-client.js +32 -6
  45. package/dist/tools/lib/clinvar-client.d.ts +35 -0
  46. package/dist/tools/lib/clinvar-client.js +34 -28
  47. package/dist/tools/lib/ctx-ops.d.ts +106 -35
  48. package/dist/tools/lib/ctx-ops.js +135 -69
  49. package/dist/tools/lib/dgidb-client.d.ts +73 -0
  50. package/dist/tools/lib/dgidb-client.js +25 -13
  51. package/dist/tools/lib/disgenet-client.d.ts +117 -24
  52. package/dist/tools/lib/disgenet-client.js +127 -34
  53. package/dist/tools/lib/disgenet-config.d.ts +1 -1
  54. package/dist/tools/lib/disgenet-config.js +5 -3
  55. package/dist/tools/lib/drugbank-client.d.ts +57 -58
  56. package/dist/tools/lib/drugbank-client.js +69 -64
  57. package/dist/tools/lib/drugbank-config.d.ts +6 -1
  58. package/dist/tools/lib/drugbank-config.js +6 -1
  59. package/dist/tools/lib/ema-client.d.ts +53 -0
  60. package/dist/tools/lib/ema-client.js +19 -7
  61. package/dist/tools/lib/ensembl-client.d.ts +4 -0
  62. package/dist/tools/lib/ensembl-client.js +4 -0
  63. package/dist/tools/lib/gwas-catalog-client.d.ts +64 -1
  64. package/dist/tools/lib/gwas-catalog-client.js +21 -6
  65. package/dist/tools/lib/identifier-resolver.d.ts +17 -1
  66. package/dist/tools/lib/identifier-resolver.js +13 -4
  67. package/dist/tools/lib/impc-client.d.ts +4 -0
  68. package/dist/tools/lib/impc-client.js +4 -0
  69. package/dist/tools/lib/iuphar-client.d.ts +10 -5
  70. package/dist/tools/lib/iuphar-client.js +49 -13
  71. package/dist/tools/lib/monarch-client.d.ts +4 -0
  72. package/dist/tools/lib/monarch-client.js +4 -0
  73. package/dist/tools/lib/openfda-client.d.ts +34 -0
  74. package/dist/tools/lib/openfda-client.js +22 -8
  75. package/dist/tools/lib/opentargets-client.d.ts +99 -4
  76. package/dist/tools/lib/opentargets-client.js +97 -37
  77. package/dist/tools/lib/pathway-client.d.ts +45 -0
  78. package/dist/tools/lib/pathway-client.js +88 -8
  79. package/dist/tools/lib/pharmgkb-client.d.ts +35 -1
  80. package/dist/tools/lib/pharmgkb-client.js +22 -6
  81. package/dist/tools/lib/pharmgkb-config.d.ts +8 -1
  82. package/dist/tools/lib/pharmgkb-config.js +8 -1
  83. package/dist/tools/lib/pubchem-client.d.ts +22 -0
  84. package/dist/tools/lib/pubchem-client.js +36 -33
  85. package/dist/tools/lib/pubchem-ops.d.ts +85 -5
  86. package/dist/tools/lib/pubchem-ops.js +96 -68
  87. package/dist/tools/lib/pubmed-client.d.ts +4 -0
  88. package/dist/tools/lib/pubmed-client.js +4 -0
  89. package/dist/tools/lib/string-client.d.ts +52 -0
  90. package/dist/tools/lib/string-client.js +12 -7
  91. package/dist/tools/lib/uniprot-client.d.ts +4 -0
  92. package/dist/tools/lib/uniprot-client.js +4 -0
  93. package/dist/tools/research/context7-docs.d.ts +23 -0
  94. package/dist/tools/research/context7-docs.js +49 -9
  95. package/dist/tools/research/search-github-repos.d.ts +4 -0
  96. package/dist/tools/research/search-github-repos.js +4 -0
  97. package/dist/workflows/execute-target-assessment.js +4 -4
  98. package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +2 -1
  99. package/dist/workflows/target-assessment/assemblers/orchestrator.js +3 -3
  100. package/dist/workflows/target-assessment/collectors/index.d.ts +8 -2
  101. package/dist/workflows/target-assessment/collectors/index.js +37 -10
  102. package/dist/workflows/target-assessment/fanout/index.d.ts +2 -1
  103. package/dist/workflows/target-assessment/fanout/index.js +2 -2
  104. package/dist/workflows/target-assessment/lib/safety-corroboration.js +2 -2
  105. package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +3 -2
  106. package/dist/workflows/target-assessment/lib/target-identity-filter.js +16 -5
  107. package/dist/workflows/target-assessment/phase4-assemble.d.ts +61 -60
  108. package/dist/workflows/target-assessment/phase4-assemble.js +2 -2
  109. package/dist/workflows/target-assessment/schemas.d.ts +2 -8
  110. package/dist/workflows/target-assessment/schemas.js +8 -2
  111. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +1 -4
  112. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -4
  113. package/package.json +1 -1
  114. package/dist/agents/sandbox/__fixtures__/deps.d.ts +0 -23
  115. package/dist/agents/sandbox/__fixtures__/deps.js +0 -102
  116. package/dist/citations/__fixtures__/resolver.d.ts +0 -3
  117. package/dist/citations/__fixtures__/resolver.js +0 -9
  118. package/dist/loop/__fixtures__/scripted-provider.d.ts +0 -31
  119. package/dist/loop/__fixtures__/scripted-provider.js +0 -81
  120. package/dist/providers/__fixtures__/session.d.ts +0 -17
  121. package/dist/providers/__fixtures__/session.js +0 -20
  122. package/dist/tools/__fixtures__/tool-context.d.ts +0 -13
  123. package/dist/tools/__fixtures__/tool-context.js +0 -22
@@ -16,6 +16,8 @@ import { BatchV1Api, CoreV1Api, KubeConfig, } from "@kubernetes/client-node";
16
16
  import { ResultAsync, err, ok } from "neverthrow";
17
17
  import { trySandbox } from "./sandbox-error.js";
18
18
  import { buildMountPlan, buildSessionSubPaths } from "./mount-plan.js";
19
+ import { threadLimitEnv } from "./thread-env.js";
20
+ import { CPU_ONLINE_PATH, CPUINFO_PATH, cpuFiles, readHostCpuinfoOnce } from "./cpu-files.js";
19
21
  import { createNoopLogger } from "../lib/console-logger.js";
20
22
  /** Read the originating HTTP status off any `SandboxError` variant that carries one. */
21
23
  function statusOf(e) {
@@ -24,6 +26,8 @@ function statusOf(e) {
24
26
  const SESSION_VOLUME_NAME = "session";
25
27
  const LIBS_VOLUME_NAME = "libs";
26
28
  const REFS_VOLUME_NAME = "refs";
29
+ /** The ConfigMap volume that holds the two cpu files of the sandbox (`cpu-files.ts`). */
30
+ const CPU_VOLUME_NAME = "cpu";
27
31
  const SANDBOX_SERVER_PORT = 8765;
28
32
  const POD_READY_TIMEOUT_MS = 5 * 60_000;
29
33
  const POD_POLL_INTERVAL_MS = 1_000;
@@ -59,6 +63,19 @@ export function sanitizeLabelValue(value) {
59
63
  function readOwnerWorkflowId(metadata) {
60
64
  return metadata?.annotations?.[OWNER_WORKFLOW_ANNOTATION] ?? null;
61
65
  }
66
+ function deleteConfigMapIgnoreMissing(coreApi, namespace, name) {
67
+ return trySandbox(async () => {
68
+ await coreApi.deleteNamespacedConfigMap({ namespace, name });
69
+ }, (status, cause) => ({
70
+ type: "teardown_failed",
71
+ op: "k8s.teardown",
72
+ sandboxId: name,
73
+ status,
74
+ cause,
75
+ })).orElse((e) =>
76
+ // A 404 means the owner reference removed it with the Job already.
77
+ statusOf(e) === 404 ? ok(undefined) : err(e));
78
+ }
62
79
  function deleteJobIgnoreMissing(batchApi, namespace, name) {
63
80
  return trySandbox(async () => {
64
81
  await batchApi.deleteNamespacedJob({
@@ -107,31 +124,32 @@ function workspaceSubPathFor(config, analysisId) {
107
124
  }
108
125
  return posix;
109
126
  }
110
- function buildJobSpec(meta, config, identity) {
127
+ function buildJobSpec(meta, config, identity, cpu) {
111
128
  const { sandboxId } = identity;
112
129
  const plan = buildMountPlan(meta, {
113
130
  libs: !!config.libStorePvc,
114
131
  refs: !!config.refStorePvc,
115
132
  });
116
133
  const transport = config.transport ?? "poll";
117
- const env = [
118
- { name: "SANDBOX_TRANSPORT", value: transport },
134
+ const spec = meta.resources;
135
+ // Composed as one record, not as a list of `{name, value}`. A duplicate name
136
+ // in the env of a container resolves at the kubelet, thus the later spread
137
+ // must win here, not there.
138
+ const env = Object.entries({
139
+ SANDBOX_TRANSPORT: transport,
119
140
  // Poll mode never dials out, so the URL is omitted (matching the Docker
120
141
  // backend) — the pod spec itself then documents that no callback egress
121
142
  // is expected. sandbox-server neither reads nor requires it in poll mode.
122
- ...(transport === "callback" ? [{ name: "CORTEX_BASE_URL", value: config.cortexBaseUrl }] : []),
123
- { name: "SANDBOX_CALLBACK_SECRET", value: identity.callbackSecret },
124
- ...Object.entries(plan.env).map(([name, value]) => ({ name, value })),
125
- ...Object.entries(meta.extraEnv ?? {}).map(([k, v]) => ({
126
- name: k,
127
- value: v,
128
- })),
129
- ];
143
+ ...(transport === "callback" ? { CORTEX_BASE_URL: config.cortexBaseUrl } : {}),
144
+ SANDBOX_CALLBACK_SECRET: identity.callbackSecret,
145
+ ...threadLimitEnv(spec),
146
+ ...plan.env,
147
+ ...(meta.extraEnv ?? {}),
148
+ }).map(([name, value]) => ({ name, value }));
130
149
  // requests == limits → Guaranteed QoS and a predictable OOM bound; also
131
150
  // satisfies the namespace ResourceQuota, which rejects any pod that omits
132
151
  // requests.cpu/memory. The CPU limit can throttle a hot step, but a throttle
133
152
  // beats an eviction.
134
- const spec = meta.resources;
135
153
  const quantities = {
136
154
  cpu: String(spec.cpu),
137
155
  memory: `${spec.memoryGb}Gi`,
@@ -187,6 +205,16 @@ function buildJobSpec(meta, config, identity) {
187
205
  readOnly: true,
188
206
  });
189
207
  }
208
+ // The cpu files ride in a ConfigMap named after the Job (`ensureCpuConfigMap`).
209
+ // A `subPath` mount of one key over one kernel path is accepted by the API
210
+ // server, the kubelet, and gVisor. Under gVisor the sandbox sees no cgroup
211
+ // at all, thus these two files are the only source of the quota for
212
+ // `parallel::detectCores()` and `os.cpu_count()`.
213
+ volumes.push({ name: CPU_VOLUME_NAME, configMap: { name: sandboxId } });
214
+ volumeMounts.push({ name: CPU_VOLUME_NAME, mountPath: CPU_ONLINE_PATH, subPath: "online", readOnly: true });
215
+ if (cpu.hasCpuinfo) {
216
+ volumeMounts.push({ name: CPU_VOLUME_NAME, mountPath: CPUINFO_PATH, subPath: "cpuinfo", readOnly: true });
217
+ }
190
218
  const podSpec = {
191
219
  automountServiceAccountToken: false,
192
220
  restartPolicy: "Never",
@@ -357,6 +385,9 @@ function waitForJobGone(batchApi, namespace, name) {
357
385
  * means an (astronomically rare) name collision with a *different* step —
358
386
  * adopting its pod would HMAC-fail every callback, and deleting its Job would
359
387
  * kill a live sibling. Refuse loudly instead.
388
+ *
389
+ * The result is the uid of the Job that the pod belongs to, for the owner
390
+ * reference of the cpu ConfigMap.
360
391
  */
361
392
  function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflowId, job) {
362
393
  const createFailed = (status, cause) => ({
@@ -369,7 +400,7 @@ function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflow
369
400
  return new ResultAsync((async () => {
370
401
  const created = await trySandbox(() => batchApi.createNamespacedJob({ namespace, body: job }), createFailed);
371
402
  if (created.isOk())
372
- return ok(undefined);
403
+ return ok(created.value?.metadata?.uid);
373
404
  if (statusOf(created.error) !== 409)
374
405
  return err(created.error);
375
406
  const existingRead = await trySandbox(() => batchApi.readNamespacedJob({ namespace, name: sandboxId }), createFailed);
@@ -397,20 +428,92 @@ function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflow
397
428
  const recreated = await trySandbox(() => batchApi.createNamespacedJob({ namespace, body: job }), createFailed);
398
429
  if (recreated.isErr())
399
430
  return err(recreated.error);
431
+ return ok(recreated.value?.metadata?.uid);
400
432
  }
433
+ return ok(existingRead.value.metadata?.uid);
434
+ })());
435
+ }
436
+ /**
437
+ * Make the cpu ConfigMap of a Job, or bring an existing one up to date.
438
+ *
439
+ * The ConfigMap comes after the Job, because the Job is its owner. Thus the
440
+ * TTL of the Job and a Foreground delete both remove the ConfigMap with it.
441
+ * The pod waits at its mount until the ConfigMap exists, and that wait is
442
+ * shorter than the schedule of the pod.
443
+ *
444
+ * A `409 AlreadyExists` is the recovery re-run of the spawn step, or a
445
+ * ConfigMap whose prior owner Job is not yet collected. Both cases get the
446
+ * same data and the current owner through a replace.
447
+ */
448
+ function ensureCpuConfigMap(coreApi, namespace, sandboxId, jobUid, files) {
449
+ const createFailed = (status, cause) => ({
450
+ type: "container_create_failed",
451
+ op: "k8s.createNamespacedConfigMap",
452
+ sandboxId,
453
+ status,
454
+ cause,
455
+ });
456
+ const body = {
457
+ apiVersion: "v1",
458
+ kind: "ConfigMap",
459
+ metadata: {
460
+ name: sandboxId,
461
+ namespace,
462
+ labels: {
463
+ [MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
464
+ role: "sandbox",
465
+ "cortex/sandbox-id": sandboxId,
466
+ },
467
+ ...(jobUid !== undefined && {
468
+ ownerReferences: [{ apiVersion: "batch/v1", kind: "Job", name: sandboxId, uid: jobUid }],
469
+ }),
470
+ },
471
+ data: {
472
+ online: files.online,
473
+ ...(files.cpuinfo !== undefined && { cpuinfo: files.cpuinfo }),
474
+ },
475
+ };
476
+ return new ResultAsync((async () => {
477
+ const created = await trySandbox(() => coreApi.createNamespacedConfigMap({ namespace, body }), createFailed);
478
+ if (created.isOk())
479
+ return ok(undefined);
480
+ if (statusOf(created.error) !== 409)
481
+ return err(created.error);
482
+ const existing = await trySandbox(() => coreApi.readNamespacedConfigMap({ namespace, name: sandboxId }), createFailed);
483
+ if (existing.isErr())
484
+ return err(existing.error);
485
+ const replaced = await trySandbox(() => coreApi.replaceNamespacedConfigMap({
486
+ namespace,
487
+ name: sandboxId,
488
+ body: { ...body, metadata: { ...body.metadata, resourceVersion: existing.value.metadata?.resourceVersion } },
489
+ }), createFailed);
490
+ if (replaced.isErr())
491
+ return err(replaced.error);
401
492
  return ok(undefined);
402
493
  })());
403
494
  }
404
495
  export function createK8sSandboxOps(config) {
405
496
  const { batchApi, coreApi } = config.batchApi && config.coreApi ? { batchApi: config.batchApi, coreApi: config.coreApi } : buildKubeApi();
497
+ const readHostCpuinfo = config.readHostCpuinfo ?? readHostCpuinfoOnce;
406
498
  return {
407
499
  createSandbox(meta, identity) {
408
500
  return new ResultAsync((async () => {
409
501
  const { sandboxId } = identity;
410
- const job = buildJobSpec(meta, config, identity);
502
+ // The same floor as `threadLimitEnv`: a fraction of a core still
503
+ // keeps one thread busy, and a count of zero is not representable.
504
+ const threads = Math.max(1, Math.floor(meta.resources.cpu));
505
+ const files = cpuFiles(threads, await readHostCpuinfo());
506
+ const job = buildJobSpec(meta, config, identity, { hasCpuinfo: files.cpuinfo !== undefined });
411
507
  const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, meta.childWorkflowId, job);
412
508
  if (adopted.isErr())
413
509
  return err(adopted.error);
510
+ const cpuMap = await ensureCpuConfigMap(coreApi, config.namespace, sandboxId, adopted.value, files);
511
+ if (cpuMap.isErr()) {
512
+ // A pod whose ConfigMap never comes stays at ContainerCreating
513
+ // until the ready wait expires. Fail now, with the real cause.
514
+ await cleanupFailedJob(batchApi, coreApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
515
+ return err(cpuMap.error);
516
+ }
414
517
  // A Job whose pod never schedules (quota rejection, no nodes, spot
415
518
  // reclaim) is reaped by neither backoffLimit (an admission rejection
416
519
  // creates no pod to count) nor ttlSecondsAfterFinished (the Job never
@@ -434,20 +537,22 @@ export function createK8sSandboxOps(config) {
434
537
  cause,
435
538
  }));
436
539
  if (registered.isErr()) {
437
- await cleanupFailedJob(batchApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
540
+ await cleanupFailedJob(batchApi, coreApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
438
541
  return err(registered.error);
439
542
  }
440
543
  return ok(ref);
441
544
  }
442
- await cleanupFailedJob(batchApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
545
+ await cleanupFailedJob(batchApi, coreApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
443
546
  return err(ready.error);
444
547
  })());
445
548
  },
549
+ // The owner reference removes the cpu ConfigMap with the Job. The explicit
550
+ // delete covers a ConfigMap that got no owner, and it is a no-op on 404.
446
551
  teardown(ref) {
447
- return deleteJobIgnoreMissing(batchApi, config.namespace, ref.sandboxId);
552
+ return deleteJobIgnoreMissing(batchApi, config.namespace, ref.sandboxId).andThen(() => deleteConfigMapIgnoreMissing(coreApi, config.namespace, ref.sandboxId));
448
553
  },
449
554
  teardownById(sandboxId) {
450
- return deleteJobIgnoreMissing(batchApi, config.namespace, sandboxId);
555
+ return deleteJobIgnoreMissing(batchApi, config.namespace, sandboxId).andThen(() => deleteConfigMapIgnoreMissing(coreApi, config.namespace, sandboxId));
451
556
  },
452
557
  listManagedSandboxes() {
453
558
  return trySandbox(() => batchApi.listNamespacedJob({
@@ -508,9 +613,13 @@ export function createK8sSandboxOps(config) {
508
613
  * matters; a delete error here is logged and swallowed so it never masks the
509
614
  * create error returned to the caller.
510
615
  */
511
- async function cleanupFailedJob(batchApi, namespace, sandboxId, logger) {
616
+ async function cleanupFailedJob(batchApi, coreApi, namespace, sandboxId, logger) {
512
617
  const deleted = await deleteJobIgnoreMissing(batchApi, namespace, sandboxId);
513
618
  if (deleted.isErr()) {
514
619
  logger.named("k8s-client").error("failed to delete Job after startup failure", { sandboxId, err: deleted.error });
515
620
  }
621
+ const deletedMap = await deleteConfigMapIgnoreMissing(coreApi, namespace, sandboxId);
622
+ if (deletedMap.isErr()) {
623
+ logger.named("k8s-client").error("failed to delete cpu ConfigMap after startup failure", { sandboxId, err: deletedMap.error });
624
+ }
516
625
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Thread-count env for a sandbox container.
3
+ *
4
+ * Each backend turns the `ResourceSpec` of a step into a hard cgroup limit:
5
+ * `NanoCpus` and `Memory` on Docker, `limits.cpu` and `limits.memory` on K8s. A
6
+ * cgroup quota is invisible to most runtimes inside it. `parallel::detectCores()`
7
+ * greps `/proc/cpuinfo`, and `os.cpu_count()` reads
8
+ * `/sys/devices/system/cpu/online`. Both files describe the host. Thus a step
9
+ * with 2 CPUs on a host of 12 cores forks 12 workers into a cgroup of 2 CPUs,
10
+ * and each fork copies its working set until the memory limit stops it.
11
+ *
12
+ * The env below publishes the quota to each library that reads a thread count
13
+ * from the environment. `parallelly::availableCores()` and `joblib.cpu_count()`
14
+ * read the cgroup quota on their own, thus they need no value here.
15
+ *
16
+ * `MC_CORES` is absent on purpose. R defaults `mclapply` to 2 workers, not to
17
+ * the core count. A value there raises the fork count instead of a decrease.
18
+ */
19
+ import type { ResourceSpec } from "../config/resource-limits.js";
20
+ /**
21
+ * Env that binds each parallel library in the container to `spec.cpu`.
22
+ *
23
+ * A fractional CPU request floors to one thread. A pool of zero is not
24
+ * representable, and two threads on half a core only add context switches.
25
+ *
26
+ * The caller puts these before `plan.env` and before the `extraEnv` of the
27
+ * embedder, thus a host that knows more about a step keeps the last word.
28
+ */
29
+ export declare function threadLimitEnv(spec: ResourceSpec): Record<string, string>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Thread-count env for a sandbox container.
3
+ *
4
+ * Each backend turns the `ResourceSpec` of a step into a hard cgroup limit:
5
+ * `NanoCpus` and `Memory` on Docker, `limits.cpu` and `limits.memory` on K8s. A
6
+ * cgroup quota is invisible to most runtimes inside it. `parallel::detectCores()`
7
+ * greps `/proc/cpuinfo`, and `os.cpu_count()` reads
8
+ * `/sys/devices/system/cpu/online`. Both files describe the host. Thus a step
9
+ * with 2 CPUs on a host of 12 cores forks 12 workers into a cgroup of 2 CPUs,
10
+ * and each fork copies its working set until the memory limit stops it.
11
+ *
12
+ * The env below publishes the quota to each library that reads a thread count
13
+ * from the environment. `parallelly::availableCores()` and `joblib.cpu_count()`
14
+ * read the cgroup quota on their own, thus they need no value here.
15
+ *
16
+ * `MC_CORES` is absent on purpose. R defaults `mclapply` to 2 workers, not to
17
+ * the core count. A value there raises the fork count instead of a decrease.
18
+ */
19
+ /**
20
+ * Env that binds each parallel library in the container to `spec.cpu`.
21
+ *
22
+ * A fractional CPU request floors to one thread. A pool of zero is not
23
+ * representable, and two threads on half a core only add context switches.
24
+ *
25
+ * The caller puts these before `plan.env` and before the `extraEnv` of the
26
+ * embedder, thus a host that knows more about a step keeps the last word.
27
+ */
28
+ export function threadLimitEnv(spec) {
29
+ const threads = String(Math.max(1, Math.floor(spec.cpu)));
30
+ return {
31
+ // OpenMP: the compiled code of R and Python packages.
32
+ OMP_NUM_THREADS: threads,
33
+ // OpenBLAS keeps a private pool. A pthread build reads this name first.
34
+ OPENBLAS_NUM_THREADS: threads,
35
+ // MKL reads this name before OMP_NUM_THREADS.
36
+ MKL_NUM_THREADS: threads,
37
+ // BiocParallel sizes `bpparam()` from this name. DESeq2 `parallel=TRUE`
38
+ // and the other Bioconductor packages size their workers from `bpparam()`.
39
+ BIOCPARALLEL_WORKER_NUMBER: threads,
40
+ // data.table defaults to half of the host cores.
41
+ R_DATATABLE_NUM_THREADS: threads,
42
+ // The Python numerical stack.
43
+ NUMBA_NUM_THREADS: threads,
44
+ NUMEXPR_MAX_THREADS: threads,
45
+ LOKY_MAX_CPU_COUNT: threads,
46
+ POLARS_MAX_THREADS: threads,
47
+ RAYON_NUM_THREADS: threads,
48
+ };
49
+ }
@@ -171,6 +171,41 @@ export declare function reconcileOrphanedDataProfile(pool: Querier, analysisId:
171
171
  * finds no running row to stamp and no-ops instead of re-seeding the cleared one.
172
172
  */
173
173
  export declare function clearDataProfile(pool: Querier, analysisId: string): ResultAsync<boolean, DbError>;
174
+ /**
175
+ * Stamp a row `completed` with no result, because the analysis has no input files.
176
+ *
177
+ * A profile describes files. An analysis with no files has nothing to describe, and
178
+ * thus the profile is complete at once: no claim into `running`, no workflow, and no
179
+ * sandbox. The write is a terminal write like {@link completeDataProfile}, but it starts
180
+ * from a settled row or an empty row, not from a `running` one. As a result
181
+ * `data_profile_started_at` and `data_profile_completed_at` carry the same instant, and
182
+ * `data_profile_workflow_id` stays NULL, because no stream is addressable (see
183
+ * {@link recordDataProfileWorkflowId}).
184
+ *
185
+ * The seed is written as `[]`, so the completed row names the set that it covers: the
186
+ * empty set. A `completed` row with a NULL seed stays forbidden (see
187
+ * {@link completeDataProfile}). A prior `data_profile_result` is replaced with NULL,
188
+ * because it describes files that the analysis no longer has. A prior `failed` row is
189
+ * replaced too, because the set that failed is gone.
190
+ *
191
+ * Two guards ride in the CAS:
192
+ * - `IS DISTINCT FROM 'running'`: a live workflow owns the row, and its terminal write
193
+ * must not land on a row that this write moved. The same reason as
194
+ * {@link clearDataProfile}.
195
+ * - {@link UNSEEDED}: the row names no file. A seed upsert that lands between a caller's
196
+ * pre-read and this write makes the row a seeded, startable row. A `completed` stamp
197
+ * over it would hide those files behind a finished profile. The CAS refuses that row,
198
+ * and the caller reads the status again to name the cause.
199
+ *
200
+ * The rerun claim carries {@link SEEDED}, so a completed-empty row is not claimable until
201
+ * a later seed upsert names files. Then `tryRerunDataProfile` takes it, the same as any
202
+ * completed row.
203
+ *
204
+ * `ok(true)` when this call stamped the row. `ok(false)` when the guard refused it: no
205
+ * such analysis, a live run, or a seed that names files. A refusal stays in the ok
206
+ * channel, the same as the sibling CAS ops.
207
+ */
208
+ export declare function completeEmptyDataProfile(pool: Querier, analysisId: string): ResultAsync<boolean, DbError>;
174
209
  /**
175
210
  * Load an analysis's data-profile ledger state. Returns `null` for BOTH miss
176
211
  * conditions, deliberately indistinguishable to consumers:
@@ -20,6 +20,13 @@ import { tryMutation, tryQuery } from "../lib/db-result.js";
20
20
  * and surfacing a hand-corrupted row as a `DbError` beats silently claiming it.
21
21
  */
22
22
  const SEEDED = "seed_input_file_ids IS NOT NULL AND jsonb_array_length(seed_input_file_ids) > 0";
23
+ /**
24
+ * The negation of {@link SEEDED}: the row names no input file. A NULL seed and `[]`
25
+ * both name zero files, and {@link completeEmptyDataProfile} treats them the same.
26
+ * The two predicates are disjoint, so a claim into `running` and an empty-set
27
+ * completion can never both win on one row.
28
+ */
29
+ const UNSEEDED = "(seed_input_file_ids IS NULL OR jsonb_array_length(seed_input_file_ids) = 0)";
23
30
  /**
24
31
  * Try to claim a startable row into `running`. Startable means `'pending'` (seeded,
25
32
  * not yet run) or NULL (no profile: never profiled, or cleared by `clearDataProfile`).
@@ -279,6 +286,55 @@ export function clearDataProfile(pool, analysisId) {
279
286
  return (result.rowCount ?? 0) > 0;
280
287
  });
281
288
  }
289
+ /**
290
+ * Stamp a row `completed` with no result, because the analysis has no input files.
291
+ *
292
+ * A profile describes files. An analysis with no files has nothing to describe, and
293
+ * thus the profile is complete at once: no claim into `running`, no workflow, and no
294
+ * sandbox. The write is a terminal write like {@link completeDataProfile}, but it starts
295
+ * from a settled row or an empty row, not from a `running` one. As a result
296
+ * `data_profile_started_at` and `data_profile_completed_at` carry the same instant, and
297
+ * `data_profile_workflow_id` stays NULL, because no stream is addressable (see
298
+ * {@link recordDataProfileWorkflowId}).
299
+ *
300
+ * The seed is written as `[]`, so the completed row names the set that it covers: the
301
+ * empty set. A `completed` row with a NULL seed stays forbidden (see
302
+ * {@link completeDataProfile}). A prior `data_profile_result` is replaced with NULL,
303
+ * because it describes files that the analysis no longer has. A prior `failed` row is
304
+ * replaced too, because the set that failed is gone.
305
+ *
306
+ * Two guards ride in the CAS:
307
+ * - `IS DISTINCT FROM 'running'`: a live workflow owns the row, and its terminal write
308
+ * must not land on a row that this write moved. The same reason as
309
+ * {@link clearDataProfile}.
310
+ * - {@link UNSEEDED}: the row names no file. A seed upsert that lands between a caller's
311
+ * pre-read and this write makes the row a seeded, startable row. A `completed` stamp
312
+ * over it would hide those files behind a finished profile. The CAS refuses that row,
313
+ * and the caller reads the status again to name the cause.
314
+ *
315
+ * The rerun claim carries {@link SEEDED}, so a completed-empty row is not claimable until
316
+ * a later seed upsert names files. Then `tryRerunDataProfile` takes it, the same as any
317
+ * completed row.
318
+ *
319
+ * `ok(true)` when this call stamped the row. `ok(false)` when the guard refused it: no
320
+ * such analysis, a live run, or a seed that names files. A refusal stays in the ok
321
+ * channel, the same as the sibling CAS ops.
322
+ */
323
+ export function completeEmptyDataProfile(pool, analysisId) {
324
+ const now = new Date().toISOString();
325
+ return tryMutation("dataProfile.completeEmptyDataProfile", async () => {
326
+ const result = await pool.query({
327
+ text: `UPDATE cortex_analysis_state
328
+ SET data_profile_status = 'completed', data_profile_error = NULL,
329
+ data_profile_started_at = $1, data_profile_completed_at = $1,
330
+ data_profile_result = NULL, data_profile_workflow_id = NULL,
331
+ seed_input_file_ids = '[]'::jsonb
332
+ WHERE analysis_id = $2 AND data_profile_status IS DISTINCT FROM 'running' AND ${UNSEEDED}`,
333
+ values: [now, analysisId],
334
+ });
335
+ return (result.rowCount ?? 0) > 0;
336
+ });
337
+ }
282
338
  /**
283
339
  * Load an analysis's data-profile ledger state. Returns `null` for BOTH miss
284
340
  * conditions, deliberately indistinguishable to consumers:
@@ -20,7 +20,7 @@ export { setSandboxRef, setActiveExecId, clearSandboxRef, queryActiveSandboxes,
20
20
  export type { ActiveSandboxRow } from "./active-sandboxes.js";
21
21
  export { insertPlan, upsertPlan, loadPlan } from "./plans.js";
22
22
  export type { InsertPlanInput, UpsertPlanInput } from "./plans.js";
23
- export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
23
+ export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, completeEmptyDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
24
24
  export type { DataProfileStatus, DataProfileResult, DataProfileChecked, DataProfileCompanionCompleteness, DataProfileDimension, DataProfileGroup, DataProfileGroupSlot, DataProfileFile, DataProfileMemberAnnotation, DataProfileObservation, DataProfilePartition, DataProfileProbeReport, DataProfileQualityAssessment, DataProfileQuarantine, DataProfileRecipeStep, DataProfileKind, DataProfileAxis, DataProfileInputSignature, DataProfileCoverage, } from "./data-profile.js";
25
25
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
26
26
  export { createAnalysisPurge } from "./purge-analysis.js";
@@ -14,7 +14,7 @@ setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis
14
14
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun } from "./step-executions.js";
15
15
  export { setSandboxRef, setActiveExecId, clearSandboxRef, queryActiveSandboxes, reconcileReapedSandbox } from "./active-sandboxes.js";
16
16
  export { insertPlan, upsertPlan, loadPlan } from "./plans.js";
17
- export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
17
+ export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, completeEmptyDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
18
18
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
19
19
  // Not per-entity: one analysis's whole Postgres footprint, reached from its id
20
20
  // alone, with the workflow ledger behind an injected seam.
@@ -132,7 +132,16 @@ export declare function registerDataProfileWorkflow(deps: DataProfileDeps): (inp
132
132
  * 'failed' on error, and revokes the run authorization on every terminal path.
133
133
  */
134
134
  export declare function runDataProfileBody(input: DataProfileWorkflowInput, deps: DataProfileDeps): Promise<void>;
135
- export type DataProfileTriggerResult = "started" | "restarted" | "already_running" | "failed";
135
+ /**
136
+ * What the trigger did:
137
+ * - `"started"`: a first profile claimed the row and a workflow runs.
138
+ * - `"restarted"`: a re-profile claimed a `completed` row and a workflow runs.
139
+ * - `"already_running"`: a workflow already owns the row, and nothing new ran.
140
+ * - `"completed"`: the analysis has no input files, so the row is `completed` at once
141
+ * with no result. No workflow ran (see {@link completeEmptyDataProfile}).
142
+ * - `"failed"`: the trigger refused the call or faulted, and the row is unchanged.
143
+ */
144
+ export type DataProfileTriggerResult = "started" | "restarted" | "already_running" | "completed" | "failed";
136
145
  /**
137
146
  * Route-side deps for triggering the data-profile workflow: the ledger pool,
138
147
  * the run authorizer, and the registered workflow callable. The body's
@@ -188,6 +197,12 @@ export declare function dataProfileWorkflowId(analysisId: string, nonce: string)
188
197
  * row is claimed by neither: retrying a failure is a deliberate act the caller drives
189
198
  * through `tryRetryDataProfile` + `runDataProfile`.
190
199
  *
200
+ * An analysis with no input files takes none of the claims. An empty manifest against
201
+ * a seed that names no file stamps the row `completed` at once, with no result and no
202
+ * workflow (see {@link completeEmptyDataProfile}), and the trigger returns
203
+ * `"completed"`. An empty manifest against a seed that names files is a divergence
204
+ * between the caller and the ledger, and the trigger refuses it.
205
+ *
191
206
  * Returns what happened, so the caller can surface it (e.g. in the seed response).
192
207
  */
193
208
  export declare function triggerDataProfile(deps: DataProfileTriggerDeps, params: DataProfileTriggerParams): Promise<DataProfileTriggerResult>;
@@ -44,7 +44,7 @@ import { scanInputTree } from "../input-scan/scan.js";
44
44
  import { PROFILE_INDEX_TYPES, buildProfileIndexEntries } from "./data-profile-index.js";
45
45
  import { absorbRecipe, renderAbsorbDelta } from "./data-profile-absorb.js";
46
46
  import { formatResolutionErrors, resolveProfileSubmission } from "./data-profile-resolve.js";
47
- import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
47
+ import { completeDataProfile, completeEmptyDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
48
48
  import { createProfileActivityEmitter } from "./data-profile-activity.js";
49
49
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
50
50
  // The run literal is declared in `contracts/` — a consumer reads it back off recorded usage, and this
@@ -668,6 +668,12 @@ async function startDataProfileWorkflow(deps, params) {
668
668
  * row is claimed by neither: retrying a failure is a deliberate act the caller drives
669
669
  * through `tryRetryDataProfile` + `runDataProfile`.
670
670
  *
671
+ * An analysis with no input files takes none of the claims. An empty manifest against
672
+ * a seed that names no file stamps the row `completed` at once, with no result and no
673
+ * workflow (see {@link completeEmptyDataProfile}), and the trigger returns
674
+ * `"completed"`. An empty manifest against a seed that names files is a divergence
675
+ * between the caller and the ledger, and the trigger refuses it.
676
+ *
671
677
  * Returns what happened, so the caller can surface it (e.g. in the seed response).
672
678
  */
673
679
  export async function triggerDataProfile(deps, params) {
@@ -682,17 +688,32 @@ export async function triggerDataProfile(deps, params) {
682
688
  // `loadSeedInputFileIds`, which returns null for BOTH a missing analysis row and
683
689
  // a NULL-seed row (`loadDataProfileStatus` would hide the seed of a cleared row).
684
690
  const seeded = unwrapOrThrow(await loadSeedInputFileIds(deps.pool, analysisId));
685
- if (seeded === null || seeded.length === 0) {
686
- logger.error("trigger rejected: no seeded input set (missing analysis row or caller skipped seeding)");
691
+ if (params.stagedInputs.length === 0) {
692
+ // The trigger validates the manifest it is about to dispatch against the ledger
693
+ // seed. A seeded row profiled against an EMPTY manifest would claim `running` and
694
+ // then hit the body's empty-manifest path, and complete with a NULL result while
695
+ // the seed still names files. Refuse the divergence before any claim.
696
+ if (seeded !== null && seeded.length > 0) {
697
+ logger.error("trigger rejected: empty manifest dispatched against a non-empty seed", { seededCount: seeded.length });
698
+ return "failed";
699
+ }
700
+ // The analysis has no input files, so there is nothing to profile. The row is
701
+ // `completed` at once, with no workflow. The CAS carries the same seed
702
+ // predicate as this pre-read, so a seed upsert that lands in between cannot be
703
+ // hidden behind a finished profile: the stamp refuses, and the status read
704
+ // below names the cause.
705
+ if (unwrapOrThrow(await completeEmptyDataProfile(deps.pool, analysisId)))
706
+ return "completed";
707
+ const current = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId));
708
+ if (current?.status === "running")
709
+ return "already_running";
710
+ logger.error("trigger rejected: empty-set completion refused (no analysis row, or the seed now names files)");
687
711
  return "failed";
688
712
  }
689
- // The trigger validates the ledger seed above; it must also validate the manifest
690
- // it is about to dispatch. A seeded row profiled against an EMPTY manifest would
691
- // claim `running` and then hit the body's empty-manifest path — completing with a
692
- // NULL result while the seed still names files, an incoherence a staleness policy
693
- // then loops on re-triggering. Refuse the divergence before any claim.
694
- if (params.stagedInputs.length === 0) {
695
- logger.error("trigger rejected: empty manifest dispatched against a non-empty seed", { seededCount: seeded.length });
713
+ // A non-empty manifest against a seed that names no file: the caller skipped the
714
+ // seed, or the analysis row is missing. Name the cause before any claim.
715
+ if (seeded === null || seeded.length === 0) {
716
+ logger.error("trigger rejected: no seeded input set (missing analysis row or caller skipped seeding)");
696
717
  return "failed";
697
718
  }
698
719
  const started = unwrapOrThrow(await tryStartDataProfile(deps.pool, analysisId));
@@ -38,9 +38,10 @@ const inputSchema = z
38
38
  .describe("Which ChEMBL lookup to run; each names its params and return fields.\n" +
39
39
  "'compounds' (query + searchType) — molecules by target, name or SMILES: resolve a named compound to its ID and structure, or list " +
40
40
  "what was assayed against a target. → chemblId, preferredCompoundName, canonicalSmiles, molecularWeight, alogp, molecularFormula.\n" +
41
- "'drug' (query) — the drug registry by drug name or indication: 'what treats X?', 'is Y approved, since when?'. → moleculeChemblId, " +
42
- "preferredName, maxPhase (4 = approved), moleculeType, firstApproval, indication. If the drug endpoint is empty it falls back to a " +
43
- "max_phase >= 4 molecule search, whose rows carry indication: null.\n" +
41
+ "'drug' (query) — approved drugs by indication or by drug name: 'what treats X?', 'is Y approved, since when?'. → moleculeChemblId, " +
42
+ "preferredName, maxPhase (4 = approved), moleculeType, firstApproval, indication. A disease term reads the curated indication " +
43
+ "registry, highest phase first; a query that matches no disease term reads the approved molecules (max_phase >= 4) instead, and " +
44
+ "their indications come back with them.\n" +
44
45
  "'mechanism' (chemblId, molecule only) — curated mechanism of ONE molecule: 'how does X work?'. → mechanismOfAction, actionType " +
45
46
  "(INHIBITOR, AGONIST, …), targetChemblId + targetName, moleculeChemblId. Curated mainly for clinical/approved molecules, so tool " +
46
47
  "compounds often have none.\n" +
@@ -18,7 +18,6 @@
18
18
  */
19
19
  import type { Logger } from "../../lib/logger.js";
20
20
  import { type DgidbResult } from "../lib/dgidb-client.js";
21
- import { type DrugResult } from "../lib/drugbank-client.js";
22
21
  import { type PharmgkbAnnotation } from "../lib/pharmgkb-client.js";
23
22
  declare const SOURCES: readonly ["dgidb", "drugbank", "pharmgkb"];
24
23
  type Source = (typeof SOURCES)[number];
@@ -30,26 +29,15 @@ interface SourceOutcome {
30
29
  }
31
30
  /**
32
31
  * A DrugBank record projected for context. `includeDrugRecord` decides whether
33
- * the five prose fields arrive whole or as previews — five 500-character fields
34
- * across ten records is 25k characters of narrative that a drug-vs-gene
35
- * question rarely needs.
32
+ * the description arrives whole or as a preview — a 500-character field across
33
+ * ten records is narrative that a drug-vs-gene question rarely needs.
36
34
  */
37
35
  interface ProjectedDrug {
38
36
  drugbankId: string;
39
37
  name: string;
40
38
  type: string;
41
39
  groups: string[];
42
- categories: string[];
43
- halfLife: string;
44
- targets: DrugResult["targets"];
45
- targetCount: number;
46
- interactions: DrugResult["interactions"];
47
- interactionCount: number;
48
40
  description: string;
49
- indication: string;
50
- pharmacodynamics: string;
51
- mechanismOfAction: string;
52
- toxicity: string;
53
41
  proseTruncated: boolean;
54
42
  }
55
43
  interface DrugGeneOutput {