@inflexa-ai/harness 0.35.2 → 0.35.4

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 (44) hide show
  1. package/dist/citations/types.d.ts +2 -2
  2. package/dist/config/environment-stores.d.ts +10 -0
  3. package/dist/contracts/schemas/chat-parts.d.ts +11 -11
  4. package/dist/execution/run-canceler.js +20 -4
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.js +5 -0
  7. package/dist/lib/metrics.d.ts +63 -5
  8. package/dist/lib/metrics.js +104 -5
  9. package/dist/lib/otel-spans.d.ts +77 -0
  10. package/dist/lib/otel-spans.js +123 -0
  11. package/dist/lib/otel.d.ts +12 -3
  12. package/dist/lib/otel.js +15 -4
  13. package/dist/loop/run-agent.js +13 -2
  14. package/dist/prompts/planner.js +6 -0
  15. package/dist/prompts/sandbox-standards.d.ts +1 -1
  16. package/dist/prompts/sandbox-standards.js +8 -3
  17. package/dist/runtime/dbos.d.ts +8 -7
  18. package/dist/sandbox/await-exec.js +16 -9
  19. package/dist/sandbox/notification-sweep.js +5 -2
  20. package/dist/sandbox/package-identity.d.ts +208 -0
  21. package/dist/sandbox/package-identity.js +244 -0
  22. package/dist/sandbox/reaper.js +5 -2
  23. package/dist/sandbox/submit-exec.js +6 -3
  24. package/dist/sandbox/types.d.ts +20 -25
  25. package/dist/sandbox/watchdog.js +8 -3
  26. package/dist/schemas/plan-schemas.d.ts +3 -3
  27. package/dist/schemas/validate-plan.js +32 -15
  28. package/dist/schemas/workflow-state.js +3 -1
  29. package/dist/state/data-profile.d.ts +20 -6
  30. package/dist/state/data-profile.js +16 -8
  31. package/dist/state/index.d.ts +1 -1
  32. package/dist/state/schema.d.ts +2 -2
  33. package/dist/tasks/data-profile.js +26 -12
  34. package/dist/tools/execute-analysis.js +37 -20
  35. package/dist/tools/research/inspect-run.d.ts +1 -1
  36. package/dist/tools/sandbox/link-packages.d.ts +6 -6
  37. package/dist/tools/sandbox/link-packages.js +45 -14
  38. package/dist/tools/sandbox/list-available-packages.d.ts +23 -1
  39. package/dist/tools/sandbox/list-available-packages.js +185 -43
  40. package/dist/workflows/execute-analysis.d.ts +4 -1
  41. package/dist/workflows/execute-analysis.js +40 -4
  42. package/dist/workflows/execute-target-assessment.js +68 -31
  43. package/dist/workflows/sandbox-step.js +7 -1
  44. package/package.json +1 -1
@@ -5,12 +5,21 @@
5
5
  * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
7
  * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
- * is set.
8
+ * is set. The provider carries the span policy of otel-spans.ts: the
9
+ * root span of a workflow declared with `untracedWorkflow` is not
10
+ * recorded (and its children with it), a replayed `cached=true` span
11
+ * is dropped before export, and a step body that calls `stableSpan`
12
+ * is exported under a stable name with its id in an `inflexa.*`
13
+ * attribute.
9
14
  *
10
15
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
11
16
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
12
- * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
13
- * metrics are defined in metrics.ts.
17
+ * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. The run and step
18
+ * outcome instruments (`cortex.run.*`, `cortex.step.*`) and the
19
+ * reconcile counters are defined in metrics.ts; the agent loop, the
20
+ * thread memory, and the workflows hold their own instruments next
21
+ * to their record sites. Every instrument binds lazily to the
22
+ * provider registered here.
14
23
  *
15
24
  * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
16
25
  * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
package/dist/lib/otel.js CHANGED
@@ -5,12 +5,21 @@
5
5
  * so the bundler cannot tree-shake it (side-effect-only imports get dropped).
6
6
  *
7
7
  * Traces: NodeTracerProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
8
- * is set.
8
+ * is set. The provider carries the span policy of otel-spans.ts: the
9
+ * root span of a workflow declared with `untracedWorkflow` is not
10
+ * recorded (and its children with it), a replayed `cached=true` span
11
+ * is dropped before export, and a step body that calls `stableSpan`
12
+ * is exported under a stable name with its id in an `inflexa.*`
13
+ * attribute.
9
14
  *
10
15
  * Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
11
16
  * set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
12
- * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
13
- * metrics are defined in metrics.ts.
17
+ * OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. The run and step
18
+ * outcome instruments (`cortex.run.*`, `cortex.step.*`) and the
19
+ * reconcile counters are defined in metrics.ts; the agent loop, the
20
+ * thread memory, and the workflows hold their own instruments next
21
+ * to their record sites. Every instrument binds lazily to the
22
+ * provider registered here.
14
23
  *
15
24
  * Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
16
25
  * env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
@@ -25,6 +34,7 @@
25
34
  */
26
35
  import { context, propagation, metrics, trace } from "@opentelemetry/api";
27
36
  import { createNoopLogger } from "./console-logger.js";
37
+ import { createHarnessSampler, DbosSpanProcessor } from "./otel-spans.js";
28
38
  import { W3CTraceContextPropagator } from "@opentelemetry/core";
29
39
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
30
40
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
@@ -92,10 +102,11 @@ export function initOtel(options = {}) {
92
102
  const spanProcessors = [];
93
103
  if (endpoint) {
94
104
  const base = endpoint.replace(/\/+$/, "");
95
- spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` })));
105
+ spanProcessors.push(new DbosSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` }))));
96
106
  }
97
107
  const tracerProvider = new NodeTracerProvider({
98
108
  resource,
109
+ sampler: createHarnessSampler(),
99
110
  spanProcessors,
100
111
  });
101
112
  tracerProvider.register();
@@ -3,6 +3,7 @@ import { jsonSchema, tool as aiTool, } from "ai";
3
3
  import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
4
4
  import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
+ import { ATTR_INFLEXA_TOOL_USE_ID, stableSpan } from "../lib/otel-spans.js";
6
7
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
7
8
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
8
9
  import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
@@ -70,7 +71,13 @@ export async function runAgent(agent, initial, session, opts) {
70
71
  session,
71
72
  signal,
72
73
  emit,
73
- runStep: (name, fn) => runStep(`${formatStepName.tool(tu.toolName, tu.toolCallId)}:${name}`, fn),
74
+ runStep: (name, fn) => {
75
+ const stepName = `${formatStepName.tool(tu.toolName, tu.toolCallId)}:${name}`;
76
+ return runStep(stepName, () => {
77
+ stableSpan(stepName, `tool:${tu.toolName}:${name}`, { [ATTR_INFLEXA_TOOL_USE_ID]: tu.toolCallId });
78
+ return fn();
79
+ });
80
+ },
74
81
  ask,
75
82
  turnUsage,
76
83
  });
@@ -540,7 +547,11 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
540
547
  }
541
548
  await Promise.all(stepTools.map(({ tu, idx }) => {
542
549
  const startedAt = performance.now();
543
- return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding)).then((settled) => {
550
+ const stepName = toolStepName(tu.toolName, tu.toolCallId);
551
+ return runStep(stepName, () => {
552
+ stableSpan(stepName, `tool:${tu.toolName}`, { [ATTR_INFLEXA_TOOL_USE_ID]: tu.toolCallId });
553
+ return dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
554
+ }).then((settled) => {
544
555
  durations[idx] = elapsedMs(startedAt);
545
556
  const dispatched = readDispatchedStep(settled);
546
557
  results[idx] = dispatched.result;
@@ -167,6 +167,12 @@ Every step carries a \`packages\` array — the packages its scripts import.
167
167
  one exact version (\`"numpy==1.26.4"\`). Never a path, a URL, or a store
168
168
  directory — a location is an installer detail, and validation refuses it.
169
169
  - An empty array is valid: the step needs nothing beyond the baked toolchain.
170
+ - When the package census shows one name under a Python section AND under an R
171
+ section, write the prefixed form the census shows: \`"python:igraph"\` or
172
+ \`"r:igraph"\`. A bare name that both tracks hold refuses the launch, because
173
+ the pool cannot tell which package you mean.
174
+ - Write a bare name for every other package. The prefix is for a both-track
175
+ name only.
170
176
  - The set is not a promise of completeness. The execution agent can still link a
171
177
  missing package mid-step. Name what you know the step imports; do not pad the
172
178
  list defensively.
@@ -42,5 +42,5 @@ export declare function sandboxOrientCorePromptFor(toolchainSource: ToolchainSou
42
42
  * constant, thus the prompt stays byte-identical across the steps of one
43
43
  * composition.
44
44
  */
45
- export declare const sandboxPackageLinkPrompt = "# Linking Packages\n\nThe host keeps a pool of staged packages beside the farm of this analysis \u2014 the\npool is part of what the host staged before this container started. The\n`link_packages` tool links a staged package into your farm \u2014 live, with no\nrestart. A link adds no new bytes and downloads nothing; it connects your farm\nto a directory that is already on the mount. So a package your farm lacks is\nnot missing until the pool has answered:\n\n- After a failed import, and when `list_available_packages` reports a package\n absent, call `link_packages` with the module name verbatim.\n- `linked` and `present` mean the import works now \u2014 run it again.\n- `absent` is a real answer: the pool does not hold the package. When\n `acquisitionPossible` is true, report the package as missing so the host can\n acquire it. Do not retry the link, and do not attempt an install.\n- `collision` is terminal for that package: the request resolves to two store\n directories. Report it and continue without the package.\n- Report a package as missing only after `link_packages` answered `absent` or\n `unavailable` for it.\n";
45
+ export declare const sandboxPackageLinkPrompt = "# Linking Packages\n\nThe host keeps a pool of staged packages beside the farm of this analysis \u2014 the\npool is part of what the host staged before this container started. The\n`link_packages` tool links a staged package into your farm \u2014 live, with no\nrestart. A link adds no new bytes and downloads nothing; it connects your farm\nto a directory that is already on the mount. So a package your farm lacks is\nnot missing until the pool has answered:\n\n- After a failed import, and when `list_available_packages` reports a package\n absent, call `link_packages` with the module name verbatim. Each entry is\n `[python:|r:]<name>[==<version>]`, and a bare name searches both tracks.\n- `linked` and `present` mean the import works now \u2014 run it again.\n- `absent` is a real answer: the pool does not hold the package. When\n `acquisitionPossible` is true, report the package as missing so the host can\n acquire it. Do not retry the link, and do not attempt an install.\n- `collision` means the request resolved to two store directories. When one\n name is held by both tracks, call `link_packages` again for that package\n with the prefixed form, `python:<name>` or `r:<name>`.\n- Drop the package only after that second call also refuses. A collision of two\n versions of one distribution is terminal at once: report it and continue\n without the package.\n- Report a package as missing only after `link_packages` answered `absent` or\n `unavailable` for it.\n";
46
46
  export declare const sandboxAnalysisStepStandardsPrompt = "# Sandbox Analysis-Step Conventions\n\nThese apply to plannable analysis steps \u2014 agents that produce reproducible\nscript + output + figure artifacts. They do NOT apply to data profiling or\nreport building (those agents opt out via\n`appendAnalysisStepStandards: false`).\n\n## Language Policy\n\nPython is the DEFAULT language. Use R when:\n(a) No adequate Python equivalent (e.g. minfi, ANCOM-BC2, ChAMP).\n(b) The R implementation is significantly more mature.\n(c) The task explicitly requires an R-only package.\n\nChoose native R or rpy2 based on scope:\n- **Isolated R calls** in a Python pipeline \u2192 rpy2 bridge.\n Example: DESeq2 for one DE step, fgsea for enrichment.\n- **R-dominant pipeline** where most steps use R packages \u2192 write\n **native R scripts**. Do not wrap an entire R pipeline in rpy2.\n Examples: microbiome (DADA2 \u2192 phyloseq \u2192 vegan \u2192 ANCOM-BC2),\n DNA methylation (minfi \u2192 ChAMP \u2192 DMRcate), untargeted metabolomics\n (XCMS \u2192 CAMERA).\n\nNative R scripts follow the same standards as Python: structured logging\n(`message()`), named constants, documented functions. Save final outputs\nas CSV (and optionally AnnData) for cross-agent consumption.\n\n## Data Format Standards\n\n- **AnnData (.h5ad)** is the UNIVERSAL container for sample-by-feature\n data across ALL modalities \u2014 bulk RNA-seq, proteomics, metabolomics,\n microarray, methylation, single-cell. Samples in `.obs`, features in\n `.var`, values in `.X`, layers in `.layers`, embeddings in `.obsm`,\n unstructured results in `.uns`.\n- **MuData (.h5mu)** for multi-modal data (CITE-seq, Multiome,\n multi-omics integration).\n- Store sample metadata in `.obs`, feature annotations in `.var`. Do\n NOT write separate metadata CSV files unless explicitly needed.\n- **Intermediate analysis objects**: save as `.h5ad` or `.h5mu`. Do\n NOT save as `.rds` or `.pkl`. When R packages produce R objects\n (DESeqDataSet, phyloseq, SingleCellExperiment), convert key results\n back to AnnData or pandas.\n- **Tabular results**: CSV with human-readable column names\n (`log2_fold_change`, `adjusted_pvalue`, not `lfc`/`padj`).\n- **Genomic data**: VCF/BED/BAM are acceptable primary containers.\n Derived summary statistics go in AnnData or CSV.\n- **Dual-format output**: when producing modality-specific results,\n also output a generic CSV for downstream cross-cutting agents.\n\n## Figure Standards\n\n- **PNG at 300 DPI** for preview AND **PDF (vector)** for publication,\n same base filename with different extensions.\n- **Colorblind-safe palettes** \u2014 viridis family as default. Never use\n red-green as the sole distinguishing feature.\n- **Clean minimal themes**: matplotlib `seaborn-v0_8-whitegrid` or\n equivalent; ggplot2 `theme_classic()` or `theme_minimal()`.\n- **Complete labels**: title, axis labels with units, legend.\n- **Statistical annotations** where applicable (p-values, significance\n brackets, effect sizes).\n- No default matplotlib/ggplot styling, no cut-off labels, no\n overlapping text.\n\n## Directory Structure\n\nYour working directory has five subdirectories \u2014 write each kind of artifact\nto its own (paths are relative to your working directory):\n\n```\nscripts/ Analysis scripts (.py, .R)\noutput/ Results: CSV, AnnData (.h5ad), MuData (.h5mu), JSON\nfigures/ Visualizations: PNG (300 DPI) + PDF (vector), same base name\nlogs/ Execution logs from long-running commands\nnotebooks/ Reserved (currently unused)\n```\n\nPut data results in `output/`, not at the working-directory root. For\nlong-running commands, redirect stdout/stderr to a file in `logs/`:\n```python\nsubprocess.run(cmd, stdout=open(\"logs/deseq2_run.log\", \"w\"), stderr=subprocess.STDOUT)\n```\n\n## Code Quality\n\n**Functions** for any non-trivial logic. Each logical operation\n(load, QC, fit, plot) gets its own function with a docstring. Top-level\nscript flow should read like an outline \u2014 call functions, not inline\nlogic.\n\n```python\n# %% [markdown]\n# ## Differential Expression\n\n# %%\ndef run_deseq2(counts: pd.DataFrame, metadata: pd.DataFrame, design: str) -> pd.DataFrame:\n \"\"\"Run DESeq2 via rpy2 and return results as a DataFrame.\"\"\"\n ...\n\nde_results = run_deseq2(counts, metadata, \"~ condition + batch\")\n```\n\n**Structured logging**, not `print()`:\n\n- Python \u2014 `logging.getLogger()`:\n `logger.info(\"Loaded %d samples, %d genes\", n_samples, n_genes)`\n- R \u2014 `message()` / `warning()`:\n `message(sprintf(\"Loaded %d samples\", n_samples))`\n\nReserve `print()` / `cat()` for intentional data display in notebook\ncells only.\n\n**Validate at function boundaries**. Fail fast with clear messages.\nUse `try/except` only when recovery is meaningful.\n\n**Type hints** on Python function signatures. Add them \u2014 they document\nintent and catch mistakes early.\n\n**Named constants** for thresholds, cutoffs, parameters in the first\ncode cell:\n```python\n# %% Parameters\nRANDOM_SEED = 42\nFDR_THRESHOLD = 0.05\nMIN_LOG2FC = 1.0\n```\n\n**R specifics**: load packages at the top (never inside functions).\nUse `<-` for assignment. Use `message()` / `warning()` not `cat()` /\n`print()`.\n\n## Scripts\n\n- Start with a header comment (title + description).\n- Parameters go in the first code block.\n- Set random seeds for reproducibility.\n- Scripts must be independently re-runnable.\n- **Lint Python scripts with `ruff check <script.py>`** via\n `execute_command` before executing. `ruff` is pre-installed. This\n catches import errors, undefined names, and common bugs early.\n\n## Cleanup\n\nWhen a script fails, delete the failed script from `scripts/` and any\npartial output files before writing a corrected version. Only final\nworking scripts should remain. Use `list_files` to verify.\n\n## Interpretation & Literature Grounding\n\nGround findings in the research literature as you work \u2014 not as a\nfinal step:\n\n1. When you identify a significant finding (DE genes, enriched\n pathways, hub genes, quality issues), search PubMed with\n `pubmed({action:\"search\"})`.\n2. For relevant hits, call `pubmed({action:\"details\"})` for abstracts and\n PMIDs. Note short citations (e.g. \"Smith et al., Nature 2023\").\n3. Assess novelty per finding: **novel** (searched, nothing found),\n **confirmed** (directly supported), **partially confirmed**,\n **contradicted**, **expected** (domain knowledge, no citation\n needed).\n4. Document the search even when no references are found. \"Novel\"\n means you looked and found nothing \u2014 not that you didn't look.\n\nYour structured output captures this interpretation. It is your\nscientific assessment of what the results mean.\n\n## Analysis-Step Anti-Patterns\n\n- **Ignoring script errors.** Read the error, diagnose the root\n cause, fix the script. Do not move on.\n- **Placeholder or dummy output.** If an analysis cannot produce\n meaningful results, report the reason clearly instead of faking\n results.\n- **Monolithic scripts.** Keep scripts under 300 lines. Split into\n logical sections with markdown annotations.\n- **`print()` for logging.** Use `logging` (Python) or `message()`\n (R). `print()` is for intentional data display only.\n- **Inline logic without functions.** Wrap non-trivial operations in\n functions with docstrings and typed parameters.\n- **Magic numbers.** Define thresholds and cutoffs as named constants.\n";
@@ -312,13 +312,18 @@ to a directory that is already on the mount. So a package your farm lacks is
312
312
  not missing until the pool has answered:
313
313
 
314
314
  - After a failed import, and when \`list_available_packages\` reports a package
315
- absent, call \`link_packages\` with the module name verbatim.
315
+ absent, call \`link_packages\` with the module name verbatim. Each entry is
316
+ \`[python:|r:]<name>[==<version>]\`, and a bare name searches both tracks.
316
317
  - \`linked\` and \`present\` mean the import works now — run it again.
317
318
  - \`absent\` is a real answer: the pool does not hold the package. When
318
319
  \`acquisitionPossible\` is true, report the package as missing so the host can
319
320
  acquire it. Do not retry the link, and do not attempt an install.
320
- - \`collision\` is terminal for that package: the request resolves to two store
321
- directories. Report it and continue without the package.
321
+ - \`collision\` means the request resolved to two store directories. When one
322
+ name is held by both tracks, call \`link_packages\` again for that package
323
+ with the prefixed form, \`python:<name>\` or \`r:<name>\`.
324
+ - Drop the package only after that second call also refuses. A collision of two
325
+ versions of one distribution is terminal at once: report it and continue
326
+ without the package.
322
327
  - Report a package as missing only after \`link_packages\` answered \`absent\` or
323
328
  \`unavailable\` for it.
324
329
  `;
@@ -55,13 +55,14 @@ export interface DbosConfig {
55
55
  * Attribute names follow the `dbos.*` semantic-convention layout
56
56
  * (`otelAttributeFormat: "semconv"`).
57
57
  *
58
- * Known SDK behavior a host must plan for:
59
- * - a recovered workflow starts a new root trace, with no link to the trace
60
- * of the original execution
61
- * - every already-completed step re-emits a `cached=true` span on replay,
62
- * so a restart with many in-flight steps bursts spans; size
63
- * `OTEL_BSP_MAX_QUEUE_SIZE` for it
64
- * - step span names embed the step, tool-use, or exec id
58
+ * The harness TracerProvider shapes what the SDK emits (`lib/otel-spans.ts`):
59
+ * a workflow declared with `untracedWorkflow` is not recorded, a replayed
60
+ * `cached=true` span is dropped before export, and a step body that calls
61
+ * `stableSpan` is exported under a stable name with its id in an
62
+ * `inflexa.*` attribute. The step name DBOS records is unchanged.
63
+ *
64
+ * Known SDK behavior a host must plan for: a recovered workflow starts a
65
+ * new root trace, with no link to the trace of the original execution.
65
66
  */
66
67
  readonly tracingEnabled?: boolean;
67
68
  }
@@ -55,6 +55,7 @@
55
55
  * varied between replays would desynchronise the recorded function-ID sequence.
56
56
  */
57
57
  import { DBOS } from "@dbos-inc/dbos-sdk";
58
+ import { ATTR_INFLEXA_ATTEMPT, ATTR_INFLEXA_EXEC_ID, stableSpan } from "../lib/otel-spans.js";
58
59
  import { digestBody } from "./digest.js";
59
60
  import { signCallback, verifyCallback } from "./hmac.js";
60
61
  import { createEscalationPolicy, probeLiveness, syntheticFailureReason, syntheticFailureResult } from "./liveness.js";
@@ -182,9 +183,11 @@ export async function awaitExecCallback(ref, execId, emit, deadlineMs, options =
182
183
  /** Ask the sandbox directly. Returns null when the exec has not finished. */
183
184
  const pull = async () => {
184
185
  pullAttempt += 1;
185
- const pulled = await runStep(() => pullExecResult(fetchImpl, ref, execId), {
186
- name: `sandbox.pull-exec-result.${execId}.${pullAttempt}`,
187
- });
186
+ const pullStepName = `sandbox.pull-exec-result.${execId}.${pullAttempt}`;
187
+ const pulled = await runStep(() => {
188
+ stableSpan(pullStepName, "sandbox.pull-exec-result", { [ATTR_INFLEXA_EXEC_ID]: execId, [ATTR_INFLEXA_ATTEMPT]: pullAttempt });
189
+ return pullExecResult(fetchImpl, ref, execId);
190
+ }, { name: pullStepName });
188
191
  if (pulled.kind !== "completed")
189
192
  return null;
190
193
  // Identical treatment to a pushed completion: the bytes were signed by
@@ -341,9 +344,11 @@ export async function awaitExecPoll(ref, execId, emit, deadlineMs, options = {})
341
344
  let probeAttempt = 0;
342
345
  while (true) {
343
346
  pollAttempt += 1;
344
- const polled = await runStep(() => pollExecOnce(fetchImpl, ref, execId, cursor), {
345
- name: `sandbox.poll-exec-result.${execId}.${pollAttempt}`,
346
- });
347
+ const pollStepName = `sandbox.poll-exec-result.${execId}.${pollAttempt}`;
348
+ const polled = await runStep(() => {
349
+ stableSpan(pollStepName, "sandbox.poll-exec-result", { [ATTR_INFLEXA_EXEC_ID]: execId, [ATTR_INFLEXA_ATTEMPT]: pollAttempt });
350
+ return pollExecOnce(fetchImpl, ref, execId, cursor);
351
+ }, { name: pollStepName });
347
352
  if (polled.kind === "ok") {
348
353
  // Identical treatment to a pushed completion: the bytes were signed by
349
354
  // the same secret, so a forgery is as fatal here as it is there.
@@ -385,9 +390,11 @@ export async function awaitExecPoll(ref, execId, emit, deadlineMs, options = {})
385
390
  }
386
391
  if (isAlive && escalation.onPoll(polled.kind === "ok" ? "ok" : "unavailable")) {
387
392
  probeAttempt += 1;
388
- const verdict = await runStep(() => probeLiveness(isAlive, ref), {
389
- name: `sandbox.probe-liveness.${execId}.${probeAttempt}`,
390
- });
393
+ const probeStepName = `sandbox.probe-liveness.${execId}.${probeAttempt}`;
394
+ const verdict = await runStep(() => {
395
+ stableSpan(probeStepName, "sandbox.probe-liveness", { [ATTR_INFLEXA_EXEC_ID]: execId, [ATTR_INFLEXA_ATTEMPT]: probeAttempt });
396
+ return probeLiveness(isAlive, ref);
397
+ }, { name: probeStepName });
391
398
  // Only an observably dead machine fails the exec — the result lives
392
399
  // in that machine and nowhere else, so it is unrecoverable and the
393
400
  // synthetic failure races nothing. `alive` (live-but-slow exec,
@@ -18,7 +18,9 @@
18
18
  */
19
19
  import { DBOS } from "@dbos-inc/dbos-sdk";
20
20
  import { createNoopLogger } from "../lib/console-logger.js";
21
+ import { untracedWorkflow } from "../lib/otel-spans.js";
21
22
  const SWEEP_CRON = "0 */5 * * * *";
23
+ const SWEEP_WORKFLOW = "dbos-notifications-sweep";
22
24
  const DELETE_BATCH_LIMIT = 10_000;
23
25
  export async function sweepStaleNotifications(deps) {
24
26
  const logger = (deps.logger ?? createNoopLogger()).named("notification-sweep");
@@ -68,9 +70,10 @@ export function registerNotificationSweep(deps) {
68
70
  // a workflow registration. Register, then schedule the same callable.
69
71
  const sweep = DBOS.registerWorkflow(async () => {
70
72
  await sweepStaleNotifications({ deleteStale, logger: deps.logger });
71
- }, { name: "dbos-notifications-sweep" });
73
+ }, { name: SWEEP_WORKFLOW });
74
+ untracedWorkflow(SWEEP_WORKFLOW);
72
75
  DBOS.registerScheduled(sweep, {
73
- name: "dbos-notifications-sweep",
76
+ name: SWEEP_WORKFLOW,
74
77
  crontab: SWEEP_CRON,
75
78
  });
76
79
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The identity of a package, the query that asks for one, and the resolution
3
+ * between the two.
4
+ *
5
+ * A package name crosses the harness, the embedder, and the provisioner. As a
6
+ * bare `string` each site applies the rule that it believes, and two sites then
7
+ * disagree on whether `decoupleR` and `decoupler` are one package. This module
8
+ * is the one place that knows the rule.
9
+ *
10
+ * Two types carry the two ideas. A {@link PackageQuery} is what a person or an
11
+ * agent asked: a spelling, an optional track, an optional version. A
12
+ * {@link PackageIdentity} is the name that an ecosystem recognizes. The
13
+ * identity is opaque, thus a caller cannot invent one, and every identity comes
14
+ * from {@link pythonIdentity}, {@link rIdentity}, or {@link identityOf}.
15
+ *
16
+ * The PEP 503 fold lives in exactly one function of this file. The provisioner
17
+ * holds the Python twin, `images/sandbox-provisioner/package_identity.py`, and
18
+ * one fixture binds the two.
19
+ */
20
+ import { type Result } from "neverthrow";
21
+ /** The two ecosystems that the pool holds. */
22
+ export type Track = "python" | "r";
23
+ /**
24
+ * What a person or an agent asked for. `spelling` is the name verbatim, as the
25
+ * caller wrote it — a remedy quotes it, and an R name is case-sensitive at
26
+ * `library()`. `track` names one ecosystem, and its absence searches both.
27
+ * `version` pins one exact version.
28
+ *
29
+ * A query is never a key. Two queries of one fold are two asks, because a
30
+ * caller that wrote `decoupleR` did not ask for `decoupler`.
31
+ */
32
+ export type PackageQuery = {
33
+ readonly spelling: string;
34
+ readonly track?: Track;
35
+ readonly version?: string;
36
+ };
37
+ /**
38
+ * The brand of an identity name. It has no runtime value: it exists so that a
39
+ * literal `{ track, name }` fails the typecheck outside this module, and thus
40
+ * every identity comes from a constructor that applied the rule of its track.
41
+ */
42
+ declare const identityBrand: unique symbol;
43
+ /**
44
+ * The name that one ecosystem recognizes, under the track that holds it.
45
+ *
46
+ * The name of a Python identity is the PEP 503 fold of the spelling, because
47
+ * PEP 503 defines the equivalence of a distribution name. The name of an R
48
+ * identity is the DESCRIPTION spelling, verbatim, because `library()` is
49
+ * case-sensitive. Two identities are equal when their keys are equal.
50
+ */
51
+ export type PackageIdentity = {
52
+ readonly track: Track;
53
+ readonly name: string & {
54
+ readonly [identityBrand]: true;
55
+ };
56
+ };
57
+ /**
58
+ * The Python identity of a spelling. The name folds, because PEP 503 makes
59
+ * `PyYAML`, `pyyaml`, and `py_yaml` one distribution.
60
+ *
61
+ * @param spelling The distribution name, as the caller spells it.
62
+ */
63
+ export declare function pythonIdentity(spelling: string): PackageIdentity;
64
+ /**
65
+ * The R identity of a name. The name keeps its spelling, because `library()`
66
+ * is case-sensitive and `GO.db` loads under that exact name.
67
+ *
68
+ * @param name The DESCRIPTION `Package` value.
69
+ */
70
+ export declare function rIdentity(name: string): PackageIdentity;
71
+ /**
72
+ * The identity of a name that an emitter already minted — a graph node, a lock
73
+ * row. The dispatch is safe over such a name, because the fold is idempotent.
74
+ *
75
+ * @param track The ecosystem that holds the name.
76
+ * @param name The name as the emitter wrote it.
77
+ */
78
+ export declare function identityOf(track: Track, name: string): PackageIdentity;
79
+ /**
80
+ * The key of an identity: `<track>:<name>`. Two identities are equal when
81
+ * their keys are equal, thus a map of identities keys on this string.
82
+ */
83
+ export declare function identityKey(identity: PackageIdentity): string;
84
+ /**
85
+ * The identity that a key names, or `undefined` when the string is not a key.
86
+ *
87
+ * The FIRST colon splits the two halves. A track name holds no colon, and
88
+ * neither a PEP 503 name nor an R name can hold one, thus `r:GO.db` gives the
89
+ * R identity `GO.db`, dot and all. The name rides through {@link identityOf},
90
+ * and the fold is idempotent, thus a key that {@link identityKey} wrote comes
91
+ * back as the identity that wrote it.
92
+ *
93
+ * The key format is minted here, thus it is read here: a reader that splits
94
+ * the string itself owns a second copy of the format.
95
+ *
96
+ * @param key A `<track>:<name>` string, as {@link identityKey} writes it.
97
+ */
98
+ export declare function parseIdentityKey(key: string): PackageIdentity | undefined;
99
+ /**
100
+ * The store address of an identity: the PEP 503 fold of its name, for both
101
+ * tracks. A store directory is an address and not an identity, thus two
102
+ * identities can share one address — `r:decoupleR` and `python:decoupler`
103
+ * both address as `decoupler`. The pin marker inside the directory carries
104
+ * the identity.
105
+ */
106
+ export declare function identityAddress(identity: PackageIdentity): string;
107
+ /**
108
+ * Why an entry is not a query.
109
+ *
110
+ * - `empty` — the entry names no package.
111
+ * - `location` — a path, a URL, or a store directory. A location is an
112
+ * installer detail, and the pool resolves names.
113
+ * - `unknown_prefix` — a `<word>:` prefix that is neither `python:` nor `r:`.
114
+ * Unrefused, such a prefix rides into the pool as part of the name.
115
+ * - `unsupported_specifier` — a specifier that is not `==`. Unrefused, a range
116
+ * such as `numpy>=1.26` becomes a package name.
117
+ */
118
+ export type ParseQueryError = {
119
+ readonly type: "empty";
120
+ } | {
121
+ readonly type: "location";
122
+ readonly entry: string;
123
+ } | {
124
+ readonly type: "unknown_prefix";
125
+ readonly prefix: string;
126
+ readonly entry: string;
127
+ } | {
128
+ readonly type: "unsupported_specifier";
129
+ readonly specifier: string;
130
+ readonly entry: string;
131
+ };
132
+ /**
133
+ * Read one entry of the grammar `[python:|r:]<spelling>[==<version>]`.
134
+ *
135
+ * The entry trims once, and the parts keep what the trim left. Every reader of
136
+ * the grammar in the harness calls this function: a second parser would
137
+ * disagree with this one, and the plan validation that passed would then meet
138
+ * a link pass that refuses.
139
+ *
140
+ * @param entry One package entry, as a plan, a tool call, or a host wrote it.
141
+ */
142
+ export declare function parseQuery(entry: string): Result<PackageQuery, ParseQueryError>;
143
+ /**
144
+ * Write a query in the grammar. The prefix rides only beside a track, and
145
+ * `==<version>` only beside a version. For every query,
146
+ * `parseQuery(formatQuery(query))` gives that query again.
147
+ */
148
+ export declare function formatQuery(query: PackageQuery): string;
149
+ /**
150
+ * What a pool answers about the identities that it holds. Two reads are
151
+ * enough for the ladder, thus a caller builds this over a graph, a lock, or a
152
+ * census without exposing its own shape.
153
+ */
154
+ export type PoolIndex = {
155
+ /** Whether the pool holds this exact identity. */
156
+ has: (identity: PackageIdentity) => boolean;
157
+ /**
158
+ * Each R identity of the pool whose address is this fold. The suggestion
159
+ * of an unknown query comes from here: a person who wrote `seurat` meant
160
+ * the R package `Seurat`.
161
+ */
162
+ rIdentitiesFoldingTo: (fold: string) => readonly PackageIdentity[];
163
+ };
164
+ /**
165
+ * What a query resolves to.
166
+ *
167
+ * - `resolved` — one identity, thus the caller reads the pool under it.
168
+ * - `ambiguous` — one spelling that both tracks hold. The caller refuses, and
169
+ * the remedy is a prefixed query.
170
+ * - `unknown` — the pool holds no identity of this query. `suggestion` names
171
+ * the R identity that the caller probably meant.
172
+ */
173
+ export type QueryResolution = {
174
+ readonly kind: "resolved";
175
+ readonly identity: PackageIdentity;
176
+ } | {
177
+ readonly kind: "ambiguous";
178
+ readonly python: PackageIdentity;
179
+ readonly r: PackageIdentity;
180
+ } | {
181
+ readonly kind: "unknown";
182
+ readonly suggestion?: PackageIdentity;
183
+ };
184
+ /**
185
+ * Resolve one query against a pool.
186
+ *
187
+ * The version takes no part, because the caller holds the versions: the graph
188
+ * orders them, and this function answers which package the query names.
189
+ *
190
+ * The ladder, in order:
191
+ *
192
+ * 1. The query names a track: the identity of that track, when the pool holds
193
+ * it. Otherwise unknown, with the R suggestion when the track is `r`.
194
+ * 2. The pool holds the R identity of the spelling and the Python identity of
195
+ * its fold: the R identity when the spelling differs from its fold, because
196
+ * an uppercase letter or a dot is evidence of an R spelling. Otherwise
197
+ * ambiguous.
198
+ * 3. The pool holds the R identity: the R identity.
199
+ * 4. The pool holds the Python identity: the Python identity.
200
+ * 5. Exactly one R identity of the pool folds to the fold of the spelling:
201
+ * unknown, with that identity as the suggestion.
202
+ * 6. Otherwise: unknown.
203
+ *
204
+ * A silent Python-first pick is a fault: it sends `decoupleR` to the Python
205
+ * `decoupler`, and the R package never links.
206
+ */
207
+ export declare function resolveQuery(query: PackageQuery, pool: PoolIndex): QueryResolution;
208
+ export {};