@inflexa-ai/harness 0.35.1 → 0.35.3
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/execution/dbos-run-launcher.d.ts +13 -2
- package/dist/execution/dbos-run-launcher.js +28 -3
- package/dist/execution/reconcile-manifest.js +8 -8
- package/dist/lib/metrics.d.ts +20 -2
- package/dist/lib/metrics.js +39 -11
- package/dist/lib/otel-spans.d.ts +77 -0
- package/dist/lib/otel-spans.js +123 -0
- package/dist/lib/otel.d.ts +48 -6
- package/dist/lib/otel.js +91 -18
- package/dist/loop/run-agent.js +13 -2
- package/dist/runtime/dbos.d.ts +48 -4
- package/dist/runtime/dbos.js +75 -13
- package/dist/sandbox/await-exec.js +16 -9
- package/dist/sandbox/notification-sweep.js +5 -2
- package/dist/sandbox/reaper.js +5 -2
- package/dist/sandbox/submit-exec.js +6 -3
- package/dist/sandbox/watchdog.js +8 -3
- package/dist/workflows/execute-analysis.js +6 -1
- package/dist/workflows/execute-target-assessment.js +9 -3
- package/dist/workflows/metrics.d.ts +1 -16
- package/dist/workflows/metrics.js +1 -19
- package/package.json +1 -1
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DBOS realization of the `RunLauncher` seam — the single production adapter,
|
|
3
|
-
* shared by every embedder. Host-neutral: it references only DBOS
|
|
4
|
-
* already depends on, so it ships in the harness.
|
|
3
|
+
* shared by every embedder. Host-neutral: it references only DBOS and the OTel
|
|
4
|
+
* API, which the harness already depends on, so it ships in the harness.
|
|
5
|
+
*
|
|
6
|
+
* A launch detaches the run from the caller's trace. The DBOS workflow span
|
|
7
|
+
* takes the active context as its parent, and the caller is a chat request
|
|
8
|
+
* (or another short-lived operation) whose span ends in milliseconds while the
|
|
9
|
+
* run lasts hours. So the launch opens a root span of its own, runs
|
|
10
|
+
* `startWorkflow` under it, and joins the two traces by a span link back to
|
|
11
|
+
* the originating span plus `inflexa.run_id` on both sides. With no
|
|
12
|
+
* TracerProvider registered every call here is a no-op on the API's
|
|
13
|
+
* non-recording span.
|
|
5
14
|
*/
|
|
6
15
|
import type { RunLauncher } from "./run-launcher.js";
|
|
16
|
+
/** Attribute carrying the run id on the originating span and on the run's root span. */
|
|
17
|
+
export declare const ATTR_INFLEXA_RUN_ID = "inflexa.run_id";
|
|
7
18
|
export declare function createDbosRunLauncher(): RunLauncher;
|
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DBOS realization of the `RunLauncher` seam — the single production adapter,
|
|
3
|
-
* shared by every embedder. Host-neutral: it references only DBOS
|
|
4
|
-
* already depends on, so it ships in the harness.
|
|
3
|
+
* shared by every embedder. Host-neutral: it references only DBOS and the OTel
|
|
4
|
+
* API, which the harness already depends on, so it ships in the harness.
|
|
5
|
+
*
|
|
6
|
+
* A launch detaches the run from the caller's trace. The DBOS workflow span
|
|
7
|
+
* takes the active context as its parent, and the caller is a chat request
|
|
8
|
+
* (or another short-lived operation) whose span ends in milliseconds while the
|
|
9
|
+
* run lasts hours. So the launch opens a root span of its own, runs
|
|
10
|
+
* `startWorkflow` under it, and joins the two traces by a span link back to
|
|
11
|
+
* the originating span plus `inflexa.run_id` on both sides. With no
|
|
12
|
+
* TracerProvider registered every call here is a no-op on the API's
|
|
13
|
+
* non-recording span.
|
|
5
14
|
*/
|
|
6
15
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
16
|
+
import { context, isSpanContextValid, ROOT_CONTEXT, trace } from "@opentelemetry/api";
|
|
17
|
+
/** Attribute carrying the run id on the originating span and on the run's root span. */
|
|
18
|
+
export const ATTR_INFLEXA_RUN_ID = "inflexa.run_id";
|
|
19
|
+
const TRACER_NAME = "inflexa.harness.run-launcher";
|
|
7
20
|
export function createDbosRunLauncher() {
|
|
8
21
|
return {
|
|
9
22
|
async launch(workflow, opts, input) {
|
|
10
|
-
|
|
23
|
+
const originating = trace.getSpan(context.active());
|
|
24
|
+
const links = [];
|
|
25
|
+
if (originating !== undefined && isSpanContextValid(originating.spanContext())) {
|
|
26
|
+
originating.setAttribute(ATTR_INFLEXA_RUN_ID, opts.workflowId);
|
|
27
|
+
links.push({ context: originating.spanContext() });
|
|
28
|
+
}
|
|
29
|
+
const root = trace.getTracer(TRACER_NAME).startSpan("launch run", { links, attributes: { [ATTR_INFLEXA_RUN_ID]: opts.workflowId } }, ROOT_CONTEXT);
|
|
30
|
+
try {
|
|
31
|
+
await context.with(trace.setSpan(ROOT_CONTEXT, root), () => DBOS.startWorkflow(workflow, { workflowID: opts.workflowId })(input));
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
root.end();
|
|
35
|
+
}
|
|
11
36
|
},
|
|
12
37
|
};
|
|
13
38
|
}
|
|
@@ -23,7 +23,7 @@ import { stat } from "node:fs/promises";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
25
25
|
import { classifyWithinRoot, computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
|
|
26
|
-
import {
|
|
26
|
+
import { recordArtifactReconcileDropped, recordLineageInputDropped } from "../lib/metrics.js";
|
|
27
27
|
export async function reconcileManifestWithDisk(input) {
|
|
28
28
|
const { workspaceRoot, resourceId, runId, stepId, agentId, manifest, collector } = input;
|
|
29
29
|
const logger = (input.logger ?? createNoopLogger()).named("reconcile-manifest").with({ runId, stepId, agentId });
|
|
@@ -62,7 +62,7 @@ export async function reconcileManifestWithDisk(input) {
|
|
|
62
62
|
logger.debug("dropping phantom entry", { path: entry.path });
|
|
63
63
|
collector.removeRecord(entry.path);
|
|
64
64
|
droppedCount++;
|
|
65
|
-
|
|
65
|
+
recordArtifactReconcileDropped({ agentId });
|
|
66
66
|
continue;
|
|
67
67
|
}
|
|
68
68
|
throw err;
|
|
@@ -75,7 +75,7 @@ export async function reconcileManifestWithDisk(input) {
|
|
|
75
75
|
logger.debug("dropping non-file entry", { path: entry.path });
|
|
76
76
|
collector.removeRecord(entry.path);
|
|
77
77
|
droppedCount++;
|
|
78
|
-
|
|
78
|
+
recordArtifactReconcileDropped({ agentId });
|
|
79
79
|
continue;
|
|
80
80
|
}
|
|
81
81
|
// The registration payload reads output hashes from `entries` (this
|
|
@@ -140,14 +140,14 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
|
|
|
140
140
|
const containerPrefix = `/${resourceId}`;
|
|
141
141
|
if (ref.path !== containerPrefix && !ref.path.startsWith(containerPrefix + "/")) {
|
|
142
142
|
logger.warn("dropping out-of-tree input from lineage", { ...attestation, boundSite: "container-prefix" });
|
|
143
|
-
|
|
143
|
+
recordLineageInputDropped({ agentId, reason: "container-prefix" });
|
|
144
144
|
collector.dropInput(ref);
|
|
145
145
|
continue;
|
|
146
146
|
}
|
|
147
147
|
const hostPath = path.join(resourceRoot, ref.path.slice(containerPrefix.length + 1));
|
|
148
148
|
if (hostPath !== resourceRoot && !hostPath.startsWith(resourceRoot + path.sep)) {
|
|
149
149
|
logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "workspace-root" });
|
|
150
|
-
|
|
150
|
+
recordLineageInputDropped({ agentId, reason: "workspace-root" });
|
|
151
151
|
collector.dropInput(ref);
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
@@ -159,7 +159,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
|
|
|
159
159
|
// verdict (a dangling link) falls through to the ENOENT drop below.
|
|
160
160
|
if ((await classifyWithinRoot(resourceRoot, hostPath)) === "escaped") {
|
|
161
161
|
logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "realpath" });
|
|
162
|
-
|
|
162
|
+
recordLineageInputDropped({ agentId, reason: "symlink-escape" });
|
|
163
163
|
collector.dropInput(ref);
|
|
164
164
|
continue;
|
|
165
165
|
}
|
|
@@ -173,7 +173,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
|
|
|
173
173
|
// consumed, and the count of those is how a reader tells a noisy
|
|
174
174
|
// hook from an artifact that genuinely vanished under the step.
|
|
175
175
|
logger.warn("dropping input not present at reconcile from lineage", { ...attestation, hostPath, dropSite: "input-enoent" });
|
|
176
|
-
|
|
176
|
+
recordLineageInputDropped({ agentId, reason: "missing" });
|
|
177
177
|
collector.dropInput(ref);
|
|
178
178
|
continue;
|
|
179
179
|
}
|
|
@@ -187,7 +187,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
|
|
|
187
187
|
// non-file drop above. A genuinely missing FILE (ENOENT) still fails fast
|
|
188
188
|
// as drift; a directory is not drift.
|
|
189
189
|
logger.debug("dropping non-file input from lineage", attestation);
|
|
190
|
-
|
|
190
|
+
recordLineageInputDropped({ agentId, reason: "directory" });
|
|
191
191
|
collector.dropInput(ref);
|
|
192
192
|
continue;
|
|
193
193
|
}
|
package/dist/lib/metrics.d.ts
CHANGED
|
@@ -4,6 +4,24 @@
|
|
|
4
4
|
* Instruments:
|
|
5
5
|
* - cortex.artifact.reconcile.dropped — counter for missing manifest entries
|
|
6
6
|
* - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
|
|
7
|
+
*
|
|
8
|
+
* Both carry `agent_id` (about 15 values) and never a per-step or per-run
|
|
9
|
+
* identifier: a label with unbounded values multiplies the series count.
|
|
10
|
+
*
|
|
11
|
+
* Instruments are lazy so the record site binds to whichever `MeterProvider`
|
|
12
|
+
* is globally registered at first use. The metrics API has no late-binding
|
|
13
|
+
* proxy: a meter taken at module load, before `initOtel` runs, would stay
|
|
14
|
+
* on the no-op provider for the life of the process.
|
|
7
15
|
*/
|
|
8
|
-
|
|
9
|
-
export declare
|
|
16
|
+
/** Record one manifest entry dropped at reconcile. */
|
|
17
|
+
export declare function recordArtifactReconcileDropped(args: {
|
|
18
|
+
readonly agentId: string;
|
|
19
|
+
}): void;
|
|
20
|
+
export type LineageInputDropReason = "container-prefix" | "workspace-root" | "symlink-escape" | "missing" | "directory";
|
|
21
|
+
/** Record one tracked input read dropped from lineage at reconcile. */
|
|
22
|
+
export declare function recordLineageInputDropped(args: {
|
|
23
|
+
readonly agentId: string;
|
|
24
|
+
readonly reason: LineageInputDropReason;
|
|
25
|
+
}): void;
|
|
26
|
+
/** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
|
|
27
|
+
export declare function __resetReconcileMetricsForTest(): void;
|
package/dist/lib/metrics.js
CHANGED
|
@@ -4,16 +4,44 @@
|
|
|
4
4
|
* Instruments:
|
|
5
5
|
* - cortex.artifact.reconcile.dropped — counter for missing manifest entries
|
|
6
6
|
* - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
|
|
7
|
+
*
|
|
8
|
+
* Both carry `agent_id` (about 15 values) and never a per-step or per-run
|
|
9
|
+
* identifier: a label with unbounded values multiplies the series count.
|
|
10
|
+
*
|
|
11
|
+
* Instruments are lazy so the record site binds to whichever `MeterProvider`
|
|
12
|
+
* is globally registered at first use. The metrics API has no late-binding
|
|
13
|
+
* proxy: a meter taken at module load, before `initOtel` runs, would stay
|
|
14
|
+
* on the no-op provider for the life of the process.
|
|
7
15
|
*/
|
|
8
16
|
import { metrics } from "@opentelemetry/api";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
let instruments;
|
|
18
|
+
function getInstruments() {
|
|
19
|
+
if (instruments === undefined) {
|
|
20
|
+
const meter = metrics.getMeter("cortex");
|
|
21
|
+
instruments = {
|
|
22
|
+
artifactReconcileDropped: meter.createCounter("cortex.artifact.reconcile.dropped", {
|
|
23
|
+
description: "Manifest entries dropped because their on-disk file was missing at " +
|
|
24
|
+
"registration time (writes-then-deletes, renames). Tagged by agent_id.",
|
|
25
|
+
}),
|
|
26
|
+
lineageInputDropped: meter.createCounter("cortex.artifact.reconcile.input_dropped", {
|
|
27
|
+
description: "Tracked input reads dropped from lineage at reconcile because they are " +
|
|
28
|
+
"not content-attestable files of the analysis (directory reads, " +
|
|
29
|
+
"out-of-tree resolutions, paths absent at reconcile). Tagged by " +
|
|
30
|
+
"agent_id, reason.",
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return instruments;
|
|
35
|
+
}
|
|
36
|
+
/** Record one manifest entry dropped at reconcile. */
|
|
37
|
+
export function recordArtifactReconcileDropped(args) {
|
|
38
|
+
getInstruments().artifactReconcileDropped.add(1, { agent_id: args.agentId });
|
|
39
|
+
}
|
|
40
|
+
/** Record one tracked input read dropped from lineage at reconcile. */
|
|
41
|
+
export function recordLineageInputDropped(args) {
|
|
42
|
+
getInstruments().lineageInputDropped.add(1, { agent_id: args.agentId, reason: args.reason });
|
|
43
|
+
}
|
|
44
|
+
/** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
|
|
45
|
+
export function __resetReconcileMetricsForTest() {
|
|
46
|
+
instruments = undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span policy of the harness TracerProvider: which DBOS traces are recorded,
|
|
3
|
+
* which spans reach the exporter, and the names the exported spans carry.
|
|
4
|
+
*
|
|
5
|
+
* DBOS names a workflow span after the registered workflow and a step span
|
|
6
|
+
* after the step function, and it runs each body under its span, so every
|
|
7
|
+
* child (a step, a patched fetch, a pg query) sits under the workflow span.
|
|
8
|
+
* Three shapes of that output are noise in a trace store, and each is stopped
|
|
9
|
+
* in the process, before export, rather than in a collector downstream. The
|
|
10
|
+
* decisions live where the workflows and steps are written; this module holds
|
|
11
|
+
* the mechanism only:
|
|
12
|
+
*
|
|
13
|
+
* - A module that registers a housekeeping workflow declares it untraced with
|
|
14
|
+
* `untracedWorkflow(name)` next to its registration. The sampler drops a
|
|
15
|
+
* root span that carries such a name at creation, and `ParentBased`
|
|
16
|
+
* sampling carries the decision to every child.
|
|
17
|
+
* - A replayed step re-emits its span with `cached=true`, which DBOS sets
|
|
18
|
+
* after the span has started. A sampler cannot see that, so
|
|
19
|
+
* `DbosSpanProcessor` drops the span at `onEnd`.
|
|
20
|
+
* - A step whose DBOS name embeds an id calls `stableSpan` as the first
|
|
21
|
+
* statement of its body. The helper renames the active DBOS span to the
|
|
22
|
+
* stable prefix and puts the id in an `inflexa.*` attribute. The DBOS step
|
|
23
|
+
* name itself is untouched: DBOS compares it against the recorded name on
|
|
24
|
+
* replay and throws `DBOSUnexpectedStepError` on a mismatch, so a rename
|
|
25
|
+
* would break every workflow in flight across a deploy.
|
|
26
|
+
*/
|
|
27
|
+
import { type Attributes, type Context } from "@opentelemetry/api";
|
|
28
|
+
import { type ReadableSpan, type Sampler, type Span, type SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
29
|
+
/**
|
|
30
|
+
* Declare a registered DBOS workflow untraced: no root span for it is
|
|
31
|
+
* recorded, and no child span under it. Call it next to the
|
|
32
|
+
* `DBOS.registerWorkflow` that gives the workflow the same name.
|
|
33
|
+
*/
|
|
34
|
+
export declare function untracedWorkflow(name: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* The sampler of the harness TracerProvider. A root span named after an
|
|
37
|
+
* untraced workflow is not recorded; every other root is. A span with a parent
|
|
38
|
+
* takes the parent's decision (the standard `ParentBased` behaviour), so a DBOS
|
|
39
|
+
* step, a fetch, or a pg span under a dropped root is dropped with it, and a
|
|
40
|
+
* remote parent (an inbound `traceparent`) is honoured as usual.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createHarnessSampler(): Sampler;
|
|
43
|
+
/** The plan step id a `compose-step-seed` step carries. */
|
|
44
|
+
export declare const ATTR_INFLEXA_STEP_ID = "inflexa.step_id";
|
|
45
|
+
/** The provider tool-use id of a tool step. */
|
|
46
|
+
export declare const ATTR_INFLEXA_TOOL_USE_ID = "inflexa.tool_use_id";
|
|
47
|
+
/** The exec id (`${workflowId}:${stepId}:${fnId}`) of a sandbox exec step. */
|
|
48
|
+
export declare const ATTR_INFLEXA_EXEC_ID = "inflexa.exec_id";
|
|
49
|
+
/** The 1-based attempt counter of a sandbox poll, pull, or liveness-probe step. */
|
|
50
|
+
export declare const ATTR_INFLEXA_ATTEMPT = "inflexa.attempt";
|
|
51
|
+
/** The per-item key (a ChEMBL or NCT id) of a target-assessment fan-out step. */
|
|
52
|
+
export declare const ATTR_INFLEXA_FANOUT_KEY = "inflexa.fanout_key";
|
|
53
|
+
/**
|
|
54
|
+
* Rename the DBOS span of the step that is running to `name` and put its id
|
|
55
|
+
* in `attributes`. Call it as the first statement of a step body whose DBOS
|
|
56
|
+
* name embeds an id, with that DBOS name as `dbosName`.
|
|
57
|
+
*
|
|
58
|
+
* DBOS runs the body under its span, so with tracing on the active span is
|
|
59
|
+
* the step span. With tracing off DBOS opens no span, and the active span, if
|
|
60
|
+
* there is one, belongs to whoever started the workflow or the chat turn. The
|
|
61
|
+
* helper therefore renames a span only when its name is `dbosName`, and it
|
|
62
|
+
* leaves a missing or non-recording span alone.
|
|
63
|
+
*/
|
|
64
|
+
export declare function stableSpan(dbosName: string, name: string, attributes: Attributes): void;
|
|
65
|
+
/**
|
|
66
|
+
* Sits in front of the exporting processor and forwards every span except a
|
|
67
|
+
* replayed one (`cached=true`), which it drops at `onEnd`.
|
|
68
|
+
*/
|
|
69
|
+
export declare class DbosSpanProcessor implements SpanProcessor {
|
|
70
|
+
private readonly next;
|
|
71
|
+
constructor(next: SpanProcessor);
|
|
72
|
+
onStart(span: Span, parentContext: Context): void;
|
|
73
|
+
onEnding(span: Span): void;
|
|
74
|
+
onEnd(span: ReadableSpan): void;
|
|
75
|
+
forceFlush(): Promise<void>;
|
|
76
|
+
shutdown(): Promise<void>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span policy of the harness TracerProvider: which DBOS traces are recorded,
|
|
3
|
+
* which spans reach the exporter, and the names the exported spans carry.
|
|
4
|
+
*
|
|
5
|
+
* DBOS names a workflow span after the registered workflow and a step span
|
|
6
|
+
* after the step function, and it runs each body under its span, so every
|
|
7
|
+
* child (a step, a patched fetch, a pg query) sits under the workflow span.
|
|
8
|
+
* Three shapes of that output are noise in a trace store, and each is stopped
|
|
9
|
+
* in the process, before export, rather than in a collector downstream. The
|
|
10
|
+
* decisions live where the workflows and steps are written; this module holds
|
|
11
|
+
* the mechanism only:
|
|
12
|
+
*
|
|
13
|
+
* - A module that registers a housekeeping workflow declares it untraced with
|
|
14
|
+
* `untracedWorkflow(name)` next to its registration. The sampler drops a
|
|
15
|
+
* root span that carries such a name at creation, and `ParentBased`
|
|
16
|
+
* sampling carries the decision to every child.
|
|
17
|
+
* - A replayed step re-emits its span with `cached=true`, which DBOS sets
|
|
18
|
+
* after the span has started. A sampler cannot see that, so
|
|
19
|
+
* `DbosSpanProcessor` drops the span at `onEnd`.
|
|
20
|
+
* - A step whose DBOS name embeds an id calls `stableSpan` as the first
|
|
21
|
+
* statement of its body. The helper renames the active DBOS span to the
|
|
22
|
+
* stable prefix and puts the id in an `inflexa.*` attribute. The DBOS step
|
|
23
|
+
* name itself is untouched: DBOS compares it against the recorded name on
|
|
24
|
+
* replay and throws `DBOSUnexpectedStepError` on a mismatch, so a rename
|
|
25
|
+
* would break every workflow in flight across a deploy.
|
|
26
|
+
*/
|
|
27
|
+
import { trace } from "@opentelemetry/api";
|
|
28
|
+
import { ParentBasedSampler, SamplingDecision, } from "@opentelemetry/sdk-trace-base";
|
|
29
|
+
/**
|
|
30
|
+
* Names of the DBOS workflows whose traces are not recorded. A registering
|
|
31
|
+
* module adds its own name at registration, which runs at boot inside
|
|
32
|
+
* `assembleCoreRuntime`, before `DBOS.launch` starts the first workflow. The
|
|
33
|
+
* sampler reads the set on every root span, so the set is complete before it
|
|
34
|
+
* is consulted, and a name added later applies from the next root span on.
|
|
35
|
+
*/
|
|
36
|
+
const untracedWorkflowNames = new Set();
|
|
37
|
+
/**
|
|
38
|
+
* Declare a registered DBOS workflow untraced: no root span for it is
|
|
39
|
+
* recorded, and no child span under it. Call it next to the
|
|
40
|
+
* `DBOS.registerWorkflow` that gives the workflow the same name.
|
|
41
|
+
*/
|
|
42
|
+
export function untracedWorkflow(name) {
|
|
43
|
+
untracedWorkflowNames.add(name);
|
|
44
|
+
}
|
|
45
|
+
/** Root decision only: `NOT_RECORD` for an untraced workflow, `RECORD_AND_SAMPLED` for every other root. */
|
|
46
|
+
class UntracedWorkflowRootSampler {
|
|
47
|
+
shouldSample(_context, _traceId, spanName) {
|
|
48
|
+
return {
|
|
49
|
+
decision: untracedWorkflowNames.has(spanName) ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
toString() {
|
|
53
|
+
return "UntracedWorkflowRootSampler";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The sampler of the harness TracerProvider. A root span named after an
|
|
58
|
+
* untraced workflow is not recorded; every other root is. A span with a parent
|
|
59
|
+
* takes the parent's decision (the standard `ParentBased` behaviour), so a DBOS
|
|
60
|
+
* step, a fetch, or a pg span under a dropped root is dropped with it, and a
|
|
61
|
+
* remote parent (an inbound `traceparent`) is honoured as usual.
|
|
62
|
+
*/
|
|
63
|
+
export function createHarnessSampler() {
|
|
64
|
+
return new ParentBasedSampler({ root: new UntracedWorkflowRootSampler() });
|
|
65
|
+
}
|
|
66
|
+
/** The plan step id a `compose-step-seed` step carries. */
|
|
67
|
+
export const ATTR_INFLEXA_STEP_ID = "inflexa.step_id";
|
|
68
|
+
/** The provider tool-use id of a tool step. */
|
|
69
|
+
export const ATTR_INFLEXA_TOOL_USE_ID = "inflexa.tool_use_id";
|
|
70
|
+
/** The exec id (`${workflowId}:${stepId}:${fnId}`) of a sandbox exec step. */
|
|
71
|
+
export const ATTR_INFLEXA_EXEC_ID = "inflexa.exec_id";
|
|
72
|
+
/** The 1-based attempt counter of a sandbox poll, pull, or liveness-probe step. */
|
|
73
|
+
export const ATTR_INFLEXA_ATTEMPT = "inflexa.attempt";
|
|
74
|
+
/** The per-item key (a ChEMBL or NCT id) of a target-assessment fan-out step. */
|
|
75
|
+
export const ATTR_INFLEXA_FANOUT_KEY = "inflexa.fanout_key";
|
|
76
|
+
/**
|
|
77
|
+
* Rename the DBOS span of the step that is running to `name` and put its id
|
|
78
|
+
* in `attributes`. Call it as the first statement of a step body whose DBOS
|
|
79
|
+
* name embeds an id, with that DBOS name as `dbosName`.
|
|
80
|
+
*
|
|
81
|
+
* DBOS runs the body under its span, so with tracing on the active span is
|
|
82
|
+
* the step span. With tracing off DBOS opens no span, and the active span, if
|
|
83
|
+
* there is one, belongs to whoever started the workflow or the chat turn. The
|
|
84
|
+
* helper therefore renames a span only when its name is `dbosName`, and it
|
|
85
|
+
* leaves a missing or non-recording span alone.
|
|
86
|
+
*/
|
|
87
|
+
export function stableSpan(dbosName, name, attributes) {
|
|
88
|
+
const span = trace.getActiveSpan();
|
|
89
|
+
if (span === undefined || !span.isRecording())
|
|
90
|
+
return;
|
|
91
|
+
// The API `Span` has no name accessor; the SDK span the provider hands out does.
|
|
92
|
+
if (span.name !== dbosName)
|
|
93
|
+
return;
|
|
94
|
+
span.updateName(name);
|
|
95
|
+
span.setAttributes(attributes);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Sits in front of the exporting processor and forwards every span except a
|
|
99
|
+
* replayed one (`cached=true`), which it drops at `onEnd`.
|
|
100
|
+
*/
|
|
101
|
+
export class DbosSpanProcessor {
|
|
102
|
+
next;
|
|
103
|
+
constructor(next) {
|
|
104
|
+
this.next = next;
|
|
105
|
+
}
|
|
106
|
+
onStart(span, parentContext) {
|
|
107
|
+
this.next.onStart(span, parentContext);
|
|
108
|
+
}
|
|
109
|
+
onEnding(span) {
|
|
110
|
+
this.next.onEnding?.(span);
|
|
111
|
+
}
|
|
112
|
+
onEnd(span) {
|
|
113
|
+
if (span.attributes.cached === true)
|
|
114
|
+
return;
|
|
115
|
+
this.next.onEnd(span);
|
|
116
|
+
}
|
|
117
|
+
forceFlush() {
|
|
118
|
+
return this.next.forceFlush();
|
|
119
|
+
}
|
|
120
|
+
shutdown() {
|
|
121
|
+
return this.next.shutdown();
|
|
122
|
+
}
|
|
123
|
+
}
|
package/dist/lib/otel.d.ts
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenTelemetry SDK initialization — traces + metrics.
|
|
3
3
|
*
|
|
4
|
-
* Exported as `initOtel()` and called explicitly from
|
|
5
|
-
* the bundler
|
|
4
|
+
* Exported as `initOtel()` and called explicitly from the host's boot sequence
|
|
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
|
-
* Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
|
|
11
|
-
*
|
|
15
|
+
* Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
|
|
16
|
+
* set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
|
|
17
|
+
* OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
|
|
18
|
+
* metrics are defined in metrics.ts.
|
|
19
|
+
*
|
|
20
|
+
* Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
|
|
21
|
+
* env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
|
|
22
|
+
* the environment wins on a conflict, so a deployment can set
|
|
23
|
+
* `deployment.environment.name` or override the service name without
|
|
24
|
+
* a code change. Traces and metrics share the one resource.
|
|
12
25
|
*
|
|
13
26
|
* Note: OTEL's instrumentation-http patches node:http but does not cover
|
|
14
27
|
* Hono's request handling or Node 22's undici-based globalThis.fetch,
|
|
@@ -16,16 +29,45 @@
|
|
|
16
29
|
* not currently wired.
|
|
17
30
|
*/
|
|
18
31
|
import type { Logger } from "./logger.js";
|
|
32
|
+
/** The SDK's own default when neither the host nor the environment says otherwise. */
|
|
33
|
+
export declare const DEFAULT_METRIC_EXPORT_INTERVAL_MS = 60000;
|
|
34
|
+
export interface InitOtelOptions {
|
|
35
|
+
/** Diagnostics sink; omitted falls back to no-op. */
|
|
36
|
+
readonly logger?: Logger;
|
|
37
|
+
/** `service.name` when `OTEL_SERVICE_NAME` is unset. Default `cortex`. */
|
|
38
|
+
readonly serviceName?: string;
|
|
39
|
+
/** `service.version` — the host's package version. Omitted leaves the attribute unset. */
|
|
40
|
+
readonly serviceVersion?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `OTEL_METRICS_EXPORTER=none` turns metric export off while the OTLP endpoint
|
|
44
|
+
* stays set for traces. Any other value (including unset) keeps the OTLP
|
|
45
|
+
* exporter, the only one the harness ships.
|
|
46
|
+
*/
|
|
47
|
+
export declare function metricsExportDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Export interval from `OTEL_METRIC_EXPORT_INTERVAL` (milliseconds). An unset,
|
|
50
|
+
* non-numeric, or non-positive value falls back to the 60 s default.
|
|
51
|
+
*/
|
|
52
|
+
export declare function metricExportIntervalMs(env?: NodeJS.ProcessEnv): number;
|
|
19
53
|
/**
|
|
20
54
|
* Initialize the OpenTelemetry SDK. Must be called before any code that
|
|
21
55
|
* calls trace.getTracer() or metrics.getMeter().
|
|
22
56
|
*
|
|
23
57
|
* Safe to call multiple times — only the first call has effect.
|
|
24
58
|
*/
|
|
25
|
-
export declare function initOtel(
|
|
59
|
+
export declare function initOtel(options?: InitOtelOptions): void;
|
|
26
60
|
/**
|
|
27
61
|
* Flush and shut down the OTel exporters. Called by the graceful-shutdown
|
|
28
62
|
* sequence so in-flight batch spans/metrics make it to the collector
|
|
29
63
|
* before the process exits. Never throws.
|
|
30
64
|
*/
|
|
31
65
|
export declare function shutdownOtel(): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Test hook: shut the providers down and unregister every global the init
|
|
68
|
+
* touched, so a test can drive `initOtel` again under a different environment.
|
|
69
|
+
* Test-only.
|
|
70
|
+
*/
|
|
71
|
+
export declare function __resetOtelForTest(): Promise<void>;
|
|
72
|
+
/** Test hook: whether `initOtel` registered a metric-exporting MeterProvider. Test-only. */
|
|
73
|
+
export declare function __otelMeterProviderRegisteredForTest(): boolean;
|
package/dist/lib/otel.js
CHANGED
|
@@ -1,47 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenTelemetry SDK initialization — traces + metrics.
|
|
3
3
|
*
|
|
4
|
-
* Exported as `initOtel()` and called explicitly from
|
|
5
|
-
* the bundler
|
|
4
|
+
* Exported as `initOtel()` and called explicitly from the host's boot sequence
|
|
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
|
-
* Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT
|
|
11
|
-
*
|
|
15
|
+
* Metrics: MeterProvider exports to OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is
|
|
16
|
+
* set and OTEL_METRICS_EXPORTER is not `none`. The export interval is
|
|
17
|
+
* OTEL_METRIC_EXPORT_INTERVAL (ms), default 60 s. Custom Cortex
|
|
18
|
+
* metrics are defined in metrics.ts.
|
|
19
|
+
*
|
|
20
|
+
* Resource: the host's `serviceName` / `serviceVersion` merged with the SDK
|
|
21
|
+
* env detector (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`);
|
|
22
|
+
* the environment wins on a conflict, so a deployment can set
|
|
23
|
+
* `deployment.environment.name` or override the service name without
|
|
24
|
+
* a code change. Traces and metrics share the one resource.
|
|
12
25
|
*
|
|
13
26
|
* Note: OTEL's instrumentation-http patches node:http but does not cover
|
|
14
27
|
* Hono's request handling or Node 22's undici-based globalThis.fetch,
|
|
15
28
|
* so inbound/outbound trace-context propagation across those paths is
|
|
16
29
|
* not currently wired.
|
|
17
30
|
*/
|
|
18
|
-
import { propagation, metrics, trace } from "@opentelemetry/api";
|
|
31
|
+
import { context, propagation, metrics, trace } from "@opentelemetry/api";
|
|
19
32
|
import { createNoopLogger } from "./console-logger.js";
|
|
33
|
+
import { createHarnessSampler, DbosSpanProcessor } from "./otel-spans.js";
|
|
20
34
|
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
21
35
|
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
22
36
|
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
23
37
|
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
24
38
|
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
25
39
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
|
26
|
-
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
27
|
-
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
40
|
+
import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
|
|
41
|
+
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
42
|
+
/** The SDK's own default when neither the host nor the environment says otherwise. */
|
|
43
|
+
export const DEFAULT_METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
28
44
|
let initialized = false;
|
|
29
45
|
let registeredTracerProvider;
|
|
30
46
|
let registeredMeterProvider;
|
|
47
|
+
/**
|
|
48
|
+
* `OTEL_METRICS_EXPORTER=none` turns metric export off while the OTLP endpoint
|
|
49
|
+
* stays set for traces. Any other value (including unset) keeps the OTLP
|
|
50
|
+
* exporter, the only one the harness ships.
|
|
51
|
+
*/
|
|
52
|
+
export function metricsExportDisabled(env = process.env) {
|
|
53
|
+
return env.OTEL_METRICS_EXPORTER === "none";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Export interval from `OTEL_METRIC_EXPORT_INTERVAL` (milliseconds). An unset,
|
|
57
|
+
* non-numeric, or non-positive value falls back to the 60 s default.
|
|
58
|
+
*/
|
|
59
|
+
export function metricExportIntervalMs(env = process.env) {
|
|
60
|
+
const raw = env.OTEL_METRIC_EXPORT_INTERVAL;
|
|
61
|
+
if (raw === undefined || raw.trim() === "")
|
|
62
|
+
return DEFAULT_METRIC_EXPORT_INTERVAL_MS;
|
|
63
|
+
const parsed = Number(raw);
|
|
64
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
65
|
+
return DEFAULT_METRIC_EXPORT_INTERVAL_MS;
|
|
66
|
+
return Math.floor(parsed);
|
|
67
|
+
}
|
|
68
|
+
function buildResource(options) {
|
|
69
|
+
const manual = {
|
|
70
|
+
[ATTR_SERVICE_NAME]: options.serviceName ?? "cortex",
|
|
71
|
+
};
|
|
72
|
+
if (options.serviceVersion)
|
|
73
|
+
manual[ATTR_SERVICE_VERSION] = options.serviceVersion;
|
|
74
|
+
// `merge` gives precedence to the argument: the environment overrides the
|
|
75
|
+
// host's values, which is the OTel SDK convention for env configuration.
|
|
76
|
+
return resourceFromAttributes(manual).merge(detectResources({ detectors: [envDetector] }));
|
|
77
|
+
}
|
|
31
78
|
/**
|
|
32
79
|
* Initialize the OpenTelemetry SDK. Must be called before any code that
|
|
33
80
|
* calls trace.getTracer() or metrics.getMeter().
|
|
34
81
|
*
|
|
35
82
|
* Safe to call multiple times — only the first call has effect.
|
|
36
83
|
*/
|
|
37
|
-
export function initOtel(
|
|
84
|
+
export function initOtel(options = {}) {
|
|
38
85
|
if (initialized)
|
|
39
86
|
return;
|
|
40
87
|
initialized = true;
|
|
41
|
-
const logger = (
|
|
88
|
+
const logger = (options.logger ?? createNoopLogger()).named("otel");
|
|
42
89
|
// ── W3C Trace Context propagation ──────────────────────────────────
|
|
43
90
|
propagation.setGlobalPropagator(new W3CTraceContextPropagator());
|
|
44
91
|
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
92
|
+
const resource = buildResource(options);
|
|
45
93
|
// ── Traces ─────────────────────────────────────────────────────────
|
|
46
94
|
// Always register a TracerProvider so trace.getTracer() returns real spans
|
|
47
95
|
// with unique IDs. Without this, the OtelBridge gets no-op spans (all zeros)
|
|
@@ -50,14 +98,11 @@ export function initOtel(injected) {
|
|
|
50
98
|
const spanProcessors = [];
|
|
51
99
|
if (endpoint) {
|
|
52
100
|
const base = endpoint.replace(/\/+$/, "");
|
|
53
|
-
spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` })));
|
|
101
|
+
spanProcessors.push(new DbosSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` }))));
|
|
54
102
|
}
|
|
55
|
-
const serviceName = process.env.OTEL_SERVICE_NAME || "cortex";
|
|
56
|
-
const resource = resourceFromAttributes({
|
|
57
|
-
[ATTR_SERVICE_NAME]: serviceName,
|
|
58
|
-
});
|
|
59
103
|
const tracerProvider = new NodeTracerProvider({
|
|
60
104
|
resource,
|
|
105
|
+
sampler: createHarnessSampler(),
|
|
61
106
|
spanProcessors,
|
|
62
107
|
});
|
|
63
108
|
tracerProvider.register();
|
|
@@ -71,20 +116,29 @@ export function initOtel(injected) {
|
|
|
71
116
|
// `initTelemetry: () => {}` — its TUI owns stdout, so the banner corrupted
|
|
72
117
|
// the screen and the whole of the harness's traces + metrics were switched
|
|
73
118
|
// off to avoid it. At debug through the injected seam it costs nothing.
|
|
74
|
-
logger.debug("TracerProvider registered", {
|
|
119
|
+
logger.debug("TracerProvider registered", {
|
|
120
|
+
spanId: ctx.spanId,
|
|
121
|
+
noop: isNoop,
|
|
122
|
+
endpoint: endpoint ?? null,
|
|
123
|
+
processors: spanProcessors.length,
|
|
124
|
+
resource: resource.attributes,
|
|
125
|
+
});
|
|
75
126
|
testSpan.end();
|
|
76
127
|
}
|
|
77
128
|
// ── Metrics ────────────────────────────────────────────────────────
|
|
78
|
-
|
|
129
|
+
// With no MeterProvider registered the API's global no-op provider serves
|
|
130
|
+
// every instrument, so the record sites stay valid when export is off.
|
|
131
|
+
if (endpoint && !metricsExportDisabled()) {
|
|
79
132
|
const base = endpoint.replace(/\/+$/, "");
|
|
80
133
|
const metricExporter = new OTLPMetricExporter({
|
|
81
134
|
url: `${base}/v1/metrics`,
|
|
82
135
|
});
|
|
83
136
|
const meterProvider = new MeterProvider({
|
|
137
|
+
resource,
|
|
84
138
|
readers: [
|
|
85
139
|
new PeriodicExportingMetricReader({
|
|
86
140
|
exporter: metricExporter,
|
|
87
|
-
exportIntervalMillis:
|
|
141
|
+
exportIntervalMillis: metricExportIntervalMs(),
|
|
88
142
|
}),
|
|
89
143
|
],
|
|
90
144
|
});
|
|
@@ -105,3 +159,22 @@ export async function shutdownOtel() {
|
|
|
105
159
|
tasks.push(registeredMeterProvider.shutdown());
|
|
106
160
|
await Promise.allSettled(tasks);
|
|
107
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Test hook: shut the providers down and unregister every global the init
|
|
164
|
+
* touched, so a test can drive `initOtel` again under a different environment.
|
|
165
|
+
* Test-only.
|
|
166
|
+
*/
|
|
167
|
+
export async function __resetOtelForTest() {
|
|
168
|
+
await shutdownOtel();
|
|
169
|
+
registeredTracerProvider = undefined;
|
|
170
|
+
registeredMeterProvider = undefined;
|
|
171
|
+
trace.disable();
|
|
172
|
+
metrics.disable();
|
|
173
|
+
context.disable();
|
|
174
|
+
propagation.disable();
|
|
175
|
+
initialized = false;
|
|
176
|
+
}
|
|
177
|
+
/** Test hook: whether `initOtel` registered a metric-exporting MeterProvider. Test-only. */
|
|
178
|
+
export function __otelMeterProviderRegisteredForTest() {
|
|
179
|
+
return registeredMeterProvider !== undefined;
|
|
180
|
+
}
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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;
|
package/dist/runtime/dbos.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* See `openspec/changes/harness-dbos-runtime`.
|
|
14
14
|
*/
|
|
15
|
+
import { type DBOSConfig, type DLogger } from "@dbos-inc/dbos-sdk";
|
|
15
16
|
import type { Pool } from "pg";
|
|
16
17
|
import type { Logger } from "../lib/logger.js";
|
|
17
18
|
/**
|
|
@@ -31,18 +32,61 @@ export interface DbosConfig {
|
|
|
31
32
|
/** Stable per-pod executor id (e.g. `process.env.HOSTNAME ?? "local-dev"`). */
|
|
32
33
|
readonly executorId: string;
|
|
33
34
|
/**
|
|
34
|
-
* SDK
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Least severe SDK record forwarded to the host's `Logger` (winston level
|
|
36
|
+
* names; the SDK's own default is `info`). The SDK's lines ride the
|
|
37
|
+
* harness `Logger` seam under `[dbos.sdk]`, so this threshold is applied by
|
|
38
|
+
* the bridge, not by the SDK. Interactive embedders pass `warn` so the
|
|
39
|
+
* launch banner and migration chatter stay out of their logs; the server
|
|
40
|
+
* root omits it and keeps the informative default.
|
|
38
41
|
*/
|
|
39
42
|
readonly logLevel?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Emit DBOS workflow and step spans on the globally registered
|
|
45
|
+
* TracerProvider, and run every workflow and step body under an active
|
|
46
|
+
* OTel context. Default `false`.
|
|
47
|
+
*
|
|
48
|
+
* DBOS installs nothing of its own here: with no OTLP endpoint of its own it
|
|
49
|
+
* probes the global provider and, when one is registered, emits under the
|
|
50
|
+
* instrumentation scope `dbos-tracer` through that provider's processors.
|
|
51
|
+
* A host that registers no provider (the CLI passes a no-op
|
|
52
|
+
* `initTelemetry`) must leave this off, otherwise the SDK installs its own
|
|
53
|
+
* `BasicTracerProvider` and every span is created and never exported.
|
|
54
|
+
*
|
|
55
|
+
* Attribute names follow the `dbos.*` semantic-convention layout
|
|
56
|
+
* (`otelAttributeFormat: "semconv"`).
|
|
57
|
+
*
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
readonly tracingEnabled?: boolean;
|
|
40
68
|
}
|
|
41
69
|
/** DBOS lifecycle facts a host's readiness probe reports on. */
|
|
42
70
|
export interface DbosState {
|
|
43
71
|
launched: boolean;
|
|
44
72
|
recoveryStarted: boolean;
|
|
45
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Bridge the SDK's internal logger onto the harness `Logger` seam, so the
|
|
76
|
+
* SDK's own lines (`Recovering N workflows…`, migration chatter) land in the
|
|
77
|
+
* host's sink instead of on the process console. The SDK does not filter by
|
|
78
|
+
* `logLevel` before it delegates to a custom logger, so the threshold is
|
|
79
|
+
* applied here. Records arrive as strings; an `Error` arrives as its message
|
|
80
|
+
* with the stack (and cause chain) in `metadata.stack`; inside a workflow or
|
|
81
|
+
* step body `metadata.span.attributes` carries the operation context, which
|
|
82
|
+
* rides as fields.
|
|
83
|
+
*/
|
|
84
|
+
export declare function dbosSdkLogger(logger: Logger, logLevel: string | undefined): DLogger;
|
|
85
|
+
/**
|
|
86
|
+
* The SDK config `launchDbos` sets. Pure over `DbosConfig`, so a test can
|
|
87
|
+
* assert on the mapping without a launch.
|
|
88
|
+
*/
|
|
89
|
+
export declare function dbosSdkConfig(config: DbosConfig, logger: Logger): DBOSConfig;
|
|
46
90
|
/**
|
|
47
91
|
* Launch DBOS. Idempotent — a second call is a no-op so tests that drive
|
|
48
92
|
* the harness twice (or accidentally double-import) don't re-launch.
|
package/dist/runtime/dbos.js
CHANGED
|
@@ -45,6 +45,76 @@ function systemDatabaseUrl(config) {
|
|
|
45
45
|
const database = encodeURIComponent(config.dbName);
|
|
46
46
|
return `postgresql://${user}:${password}@${host}:${port}/${database}?sslmode=${dbosSslMode(config.dbSslMode)}`;
|
|
47
47
|
}
|
|
48
|
+
/** Winston severity ranks, ascending verbosity — the scale `DbosConfig.logLevel` names. */
|
|
49
|
+
const SDK_LOG_LEVEL_RANK = {
|
|
50
|
+
error: 0,
|
|
51
|
+
warn: 1,
|
|
52
|
+
info: 2,
|
|
53
|
+
http: 3,
|
|
54
|
+
verbose: 4,
|
|
55
|
+
debug: 5,
|
|
56
|
+
silly: 6,
|
|
57
|
+
};
|
|
58
|
+
const SDK_DEFAULT_LOG_LEVEL = "info";
|
|
59
|
+
/**
|
|
60
|
+
* Bridge the SDK's internal logger onto the harness `Logger` seam, so the
|
|
61
|
+
* SDK's own lines (`Recovering N workflows…`, migration chatter) land in the
|
|
62
|
+
* host's sink instead of on the process console. The SDK does not filter by
|
|
63
|
+
* `logLevel` before it delegates to a custom logger, so the threshold is
|
|
64
|
+
* applied here. Records arrive as strings; an `Error` arrives as its message
|
|
65
|
+
* with the stack (and cause chain) in `metadata.stack`; inside a workflow or
|
|
66
|
+
* step body `metadata.span.attributes` carries the operation context, which
|
|
67
|
+
* rides as fields.
|
|
68
|
+
*/
|
|
69
|
+
export function dbosSdkLogger(logger, logLevel) {
|
|
70
|
+
const sink = logger.named("sdk");
|
|
71
|
+
const threshold = SDK_LOG_LEVEL_RANK[logLevel ?? SDK_DEFAULT_LOG_LEVEL] ?? SDK_LOG_LEVEL_RANK[SDK_DEFAULT_LOG_LEVEL];
|
|
72
|
+
const passes = (level) => SDK_LOG_LEVEL_RANK[level] <= threshold;
|
|
73
|
+
const fields = (metadata) => {
|
|
74
|
+
const attributes = metadata?.span?.attributes;
|
|
75
|
+
const stack = metadata?.stack;
|
|
76
|
+
if (attributes === undefined && stack === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
return { ...attributes, ...(stack !== undefined ? { stack } : {}) };
|
|
79
|
+
};
|
|
80
|
+
const message = (entry) => (typeof entry === "string" ? entry : String(entry));
|
|
81
|
+
return {
|
|
82
|
+
debug: (entry, metadata) => {
|
|
83
|
+
if (passes("debug"))
|
|
84
|
+
sink.debug(message(entry), fields(metadata));
|
|
85
|
+
},
|
|
86
|
+
info: (entry, metadata) => {
|
|
87
|
+
if (passes("info"))
|
|
88
|
+
sink.info(message(entry), fields(metadata));
|
|
89
|
+
},
|
|
90
|
+
warn: (entry, metadata) => {
|
|
91
|
+
if (passes("warn"))
|
|
92
|
+
sink.warn(message(entry), fields(metadata));
|
|
93
|
+
},
|
|
94
|
+
error: (entry, metadata) => {
|
|
95
|
+
if (passes("error"))
|
|
96
|
+
sink.error(message(entry), fields(metadata));
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The SDK config `launchDbos` sets. Pure over `DbosConfig`, so a test can
|
|
102
|
+
* assert on the mapping without a launch.
|
|
103
|
+
*/
|
|
104
|
+
export function dbosSdkConfig(config, logger) {
|
|
105
|
+
return {
|
|
106
|
+
name: config.appName,
|
|
107
|
+
systemDatabaseUrl: systemDatabaseUrl(config),
|
|
108
|
+
systemDatabasePoolSize: DBOS_SYSTEM_POOL_SIZE,
|
|
109
|
+
executorID: config.executorId,
|
|
110
|
+
applicationVersion: config.applicationVersion,
|
|
111
|
+
adminPort: parseInt(config.adminPort, 10),
|
|
112
|
+
logLevel: config.logLevel,
|
|
113
|
+
logger: dbosSdkLogger(logger, config.logLevel),
|
|
114
|
+
tracingEnabled: config.tracingEnabled ?? false,
|
|
115
|
+
otelAttributeFormat: "semconv",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
48
118
|
/**
|
|
49
119
|
* Launch DBOS. Idempotent — a second call is a no-op so tests that drive
|
|
50
120
|
* the harness twice (or accidentally double-import) don't re-launch.
|
|
@@ -57,25 +127,17 @@ export async function launchDbos({ config, logger: injected }) {
|
|
|
57
127
|
if (state.launched)
|
|
58
128
|
return;
|
|
59
129
|
const logger = injected.named("dbos");
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
DBOS.setConfig({
|
|
63
|
-
name: config.appName,
|
|
64
|
-
systemDatabaseUrl: systemDatabaseUrl(config),
|
|
65
|
-
systemDatabasePoolSize: DBOS_SYSTEM_POOL_SIZE,
|
|
66
|
-
executorID,
|
|
67
|
-
applicationVersion: config.applicationVersion,
|
|
68
|
-
adminPort,
|
|
69
|
-
logLevel: config.logLevel,
|
|
70
|
-
});
|
|
130
|
+
const sdkConfig = dbosSdkConfig(config, logger);
|
|
131
|
+
DBOS.setConfig(sdkConfig);
|
|
71
132
|
const start = performance.now();
|
|
72
133
|
await DBOS.launch();
|
|
73
134
|
state.launched = true;
|
|
74
135
|
state.recoveryStarted = true;
|
|
75
136
|
logger.info("launched", {
|
|
76
|
-
executorID,
|
|
137
|
+
executorID: sdkConfig.executorID,
|
|
77
138
|
applicationVersion: config.applicationVersion,
|
|
78
|
-
adminPort,
|
|
139
|
+
adminPort: sdkConfig.adminPort,
|
|
140
|
+
tracingEnabled: sdkConfig.tracingEnabled,
|
|
79
141
|
durationMs: Math.round(performance.now() - start),
|
|
80
142
|
});
|
|
81
143
|
}
|
|
@@ -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
|
|
186
|
-
|
|
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
|
|
345
|
-
|
|
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
|
|
389
|
-
|
|
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:
|
|
73
|
+
}, { name: SWEEP_WORKFLOW });
|
|
74
|
+
untracedWorkflow(SWEEP_WORKFLOW);
|
|
72
75
|
DBOS.registerScheduled(sweep, {
|
|
73
|
-
name:
|
|
76
|
+
name: SWEEP_WORKFLOW,
|
|
74
77
|
crontab: SWEEP_CRON,
|
|
75
78
|
});
|
|
76
79
|
}
|
package/dist/sandbox/reaper.js
CHANGED
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
23
23
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
24
|
+
import { untracedWorkflow } from "../lib/otel-spans.js";
|
|
24
25
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
25
26
|
import { reconcileReapedSandbox } from "../state/active-sandboxes.js";
|
|
26
27
|
const REAPER_CRON = "0 */5 * * * *";
|
|
28
|
+
const REAPER_WORKFLOW = "sandbox-reaper";
|
|
27
29
|
const DEFAULT_GRACE_MS = 10 * 60_000;
|
|
28
30
|
/**
|
|
29
31
|
* Workflow statuses that mean "still owns its sandbox — never reap". Must
|
|
@@ -152,9 +154,10 @@ export function registerSandboxReaper(deps) {
|
|
|
152
154
|
graceMs: deps.graceMs,
|
|
153
155
|
logger: deps.logger,
|
|
154
156
|
}), { name: "sandbox-reaper-sweep" });
|
|
155
|
-
}, { name:
|
|
157
|
+
}, { name: REAPER_WORKFLOW });
|
|
158
|
+
untracedWorkflow(REAPER_WORKFLOW);
|
|
156
159
|
DBOS.registerScheduled(reaper, {
|
|
157
|
-
name:
|
|
160
|
+
name: REAPER_WORKFLOW,
|
|
158
161
|
crontab: REAPER_CRON,
|
|
159
162
|
});
|
|
160
163
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* untouched.
|
|
18
18
|
*/
|
|
19
19
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
20
|
+
import { ATTR_INFLEXA_EXEC_ID, stableSpan } from "../lib/otel-spans.js";
|
|
20
21
|
import { EXEC_STREAM_BYTE_CAP } from "../tools/workspace/result-bounds.js";
|
|
21
22
|
import { signCallback } from "./hmac.js";
|
|
22
23
|
const SUBMIT_HTTP_TIMEOUT_MS = 30_000;
|
|
@@ -71,7 +72,9 @@ export async function submitExec(ref, body, deps = {}) {
|
|
|
71
72
|
stdoutByteCap: body.stdoutByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
|
|
72
73
|
stderrByteCap: body.stderrByteCap ?? deps.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP,
|
|
73
74
|
};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const stepName = `sandbox.submit-exec.${body.execId}`;
|
|
76
|
+
await runStep(() => {
|
|
77
|
+
stableSpan(stepName, "sandbox.submit-exec", { [ATTR_INFLEXA_EXEC_ID]: body.execId });
|
|
78
|
+
return postExec(fetchImpl, ref, capped);
|
|
79
|
+
}, { name: stepName });
|
|
77
80
|
}
|
package/dist/sandbox/watchdog.js
CHANGED
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
23
23
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
24
|
+
import { untracedWorkflow } from "../lib/otel-spans.js";
|
|
24
25
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
25
26
|
import { workflowIdFromExec } from "./exec-id.js";
|
|
26
27
|
import { syntheticFailureReason, syntheticFailureResult } from "./liveness.js";
|
|
27
28
|
export const SHARD_COUNT = 8;
|
|
28
29
|
const WATCHDOG_CRON = "*/60 * * * * *";
|
|
30
|
+
const WATCHDOG_PARENT_WORKFLOW = "sandbox-watchdog-parent";
|
|
31
|
+
const WATCHDOG_SHARD_WORKFLOW = "sandbox-watchdog-shard";
|
|
29
32
|
/** Stable hash → shard mapping, exported for tests. */
|
|
30
33
|
export function shardIndex(sandboxId, shardCount = SHARD_COUNT) {
|
|
31
34
|
const digest = createHash("sha1").update(sandboxId).digest();
|
|
@@ -131,7 +134,8 @@ export function registerWatchdog(deps) {
|
|
|
131
134
|
},
|
|
132
135
|
logger: deps.logger,
|
|
133
136
|
});
|
|
134
|
-
}, { name:
|
|
137
|
+
}, { name: WATCHDOG_SHARD_WORKFLOW });
|
|
138
|
+
untracedWorkflow(WATCHDOG_SHARD_WORKFLOW);
|
|
135
139
|
// The scheduled target must itself be a registered workflow — DBOS's
|
|
136
140
|
// scheduler loop skips (and errors on) any `@scheduled` function that lacks
|
|
137
141
|
// a workflow registration. Register, then schedule the same callable.
|
|
@@ -148,9 +152,10 @@ export function registerWatchdog(deps) {
|
|
|
148
152
|
continue;
|
|
149
153
|
await DBOS.startWorkflow(childCheckShard)(shard);
|
|
150
154
|
}
|
|
151
|
-
}, { name:
|
|
155
|
+
}, { name: WATCHDOG_PARENT_WORKFLOW });
|
|
156
|
+
untracedWorkflow(WATCHDOG_PARENT_WORKFLOW);
|
|
152
157
|
DBOS.registerScheduled(watchdogParent, {
|
|
153
|
-
name:
|
|
158
|
+
name: WATCHDOG_PARENT_WORKFLOW,
|
|
154
159
|
crontab: WATCHDOG_CRON,
|
|
155
160
|
});
|
|
156
161
|
}
|
|
@@ -63,6 +63,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
63
63
|
import { join } from "node:path";
|
|
64
64
|
import { forStep } from "../auth/types.js";
|
|
65
65
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
66
|
+
import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
|
|
66
67
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
67
68
|
import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
68
69
|
import { isBudgetExceeded } from "../loop/budget-exceeded.js";
|
|
@@ -770,7 +771,11 @@ async function runSchedulerLoop(args) {
|
|
|
770
771
|
// `completed`, so this is the first (and only) moment their results
|
|
771
772
|
// can be named. Checkpointed so a replay re-dispatches the child
|
|
772
773
|
// with a byte-identical prompt instead of re-reading the DB/disk.
|
|
773
|
-
const
|
|
774
|
+
const seedStepName = `compose-step-seed:${stepId}`;
|
|
775
|
+
const prompt = await DBOS.runStep(() => {
|
|
776
|
+
stableSpan(seedStepName, "compose-step-seed", { [ATTR_INFLEXA_STEP_ID]: stepId });
|
|
777
|
+
return composeStepSeed({ input, stepId, runId, deps });
|
|
778
|
+
}, { name: seedStepName });
|
|
774
779
|
const baseChildInput = buildChildInput({
|
|
775
780
|
input,
|
|
776
781
|
stepId,
|
|
@@ -37,6 +37,7 @@ import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
|
37
37
|
import { z } from "zod";
|
|
38
38
|
import { forSubAgent } from "../auth/types.js";
|
|
39
39
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
40
|
+
import { ATTR_INFLEXA_FANOUT_KEY, stableSpan } from "../lib/otel-spans.js";
|
|
40
41
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
41
42
|
import { durableStep } from "../loop/run-step.js";
|
|
42
43
|
import { createPubMedTool } from "../tools/bio/pubmed.js";
|
|
@@ -278,9 +279,14 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
278
279
|
}))
|
|
279
280
|
: [];
|
|
280
281
|
async function runFanoutBlock(items, stepKindForName, keyOf, fn) {
|
|
281
|
-
return Promise.all(items.map((item) =>
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
return Promise.all(items.map((item) => {
|
|
283
|
+
const key = keyOf(item);
|
|
284
|
+
const stepName = `ta-fanout:${stepKindForName}:${key}`;
|
|
285
|
+
return DBOS.runStep(() => {
|
|
286
|
+
stableSpan(stepName, `ta-fanout:${stepKindForName}`, { [ATTR_INFLEXA_FANOUT_KEY]: key });
|
|
287
|
+
return fn(item);
|
|
288
|
+
}, { name: stepName }).catch((err) => wrapCollectorThrow(err));
|
|
289
|
+
}));
|
|
284
290
|
}
|
|
285
291
|
const [perModulatorFaersResults, perTrialAesResults, perModulatorPolypharmResults, perClassDrugAesResults] = await Promise.all([
|
|
286
292
|
runFanoutBlock(triageShortlist, "modulator-faers", (item) => item.moleculeChemblId, faersForOneModulator),
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workflow-side OTel metrics
|
|
2
|
+
* Workflow-side OTel metrics.
|
|
3
3
|
*
|
|
4
|
-
* Two signals so 3-hour runs and pause-resume cascades are observable:
|
|
5
4
|
* - cortex.workflow.parent.cancelled_children — counter, incremented once
|
|
6
5
|
* per child the fail-fast or pause cascade reaps. A baseline near 0
|
|
7
6
|
* means runs land cleanly; a spike means siblings keep colliding with
|
|
8
7
|
* a single failing step.
|
|
9
|
-
* - cortex.workflow.stream.bytes — counter, incremented by
|
|
10
|
-
* the byte count of every UI part written onto the DBOS stream. The
|
|
11
|
-
* leading indicator for 3-hour stream growth is monotonic — a runaway
|
|
12
|
-
* emitter shows up as an unbounded slope.
|
|
13
8
|
*
|
|
14
9
|
* Instruments are lazy so the workflow body binds to whichever
|
|
15
10
|
* `MeterProvider` is globally registered at startup.
|
|
@@ -23,15 +18,5 @@
|
|
|
23
18
|
export declare function recordCancelledChild(args: {
|
|
24
19
|
readonly cause: "fail_fast" | "budget_exceeded" | "external_cancel";
|
|
25
20
|
}): void;
|
|
26
|
-
/**
|
|
27
|
-
* Record one UI-part write. `bytes` is the serialised JSON size of the
|
|
28
|
-
* part as it lands on the stream; `kind` is the part `type` for
|
|
29
|
-
* dashboarding. The workflow body wraps its `DBOS.writeStream` call site
|
|
30
|
-
* so a single emitter records once per part.
|
|
31
|
-
*/
|
|
32
|
-
export declare function recordStreamWrite(args: {
|
|
33
|
-
readonly bytes: number;
|
|
34
|
-
readonly kind: string;
|
|
35
|
-
}): void;
|
|
36
21
|
/** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
|
|
37
22
|
export declare function __resetWorkflowMetricsForTest(): void;
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workflow-side OTel metrics
|
|
2
|
+
* Workflow-side OTel metrics.
|
|
3
3
|
*
|
|
4
|
-
* Two signals so 3-hour runs and pause-resume cascades are observable:
|
|
5
4
|
* - cortex.workflow.parent.cancelled_children — counter, incremented once
|
|
6
5
|
* per child the fail-fast or pause cascade reaps. A baseline near 0
|
|
7
6
|
* means runs land cleanly; a spike means siblings keep colliding with
|
|
8
7
|
* a single failing step.
|
|
9
|
-
* - cortex.workflow.stream.bytes — counter, incremented by
|
|
10
|
-
* the byte count of every UI part written onto the DBOS stream. The
|
|
11
|
-
* leading indicator for 3-hour stream growth is monotonic — a runaway
|
|
12
|
-
* emitter shows up as an unbounded slope.
|
|
13
8
|
*
|
|
14
9
|
* Instruments are lazy so the workflow body binds to whichever
|
|
15
10
|
* `MeterProvider` is globally registered at startup.
|
|
@@ -23,10 +18,6 @@ function getInstruments() {
|
|
|
23
18
|
cancelledChildren: meter.createCounter("cortex.workflow.parent.cancelled_children", {
|
|
24
19
|
description: "Children cancelled by the parent's fail-fast or pause cascade",
|
|
25
20
|
}),
|
|
26
|
-
streamBytes: meter.createCounter("cortex.workflow.stream.bytes", {
|
|
27
|
-
description: "Bytes written to the parent's DBOS UI-parts stream — proxy for " + "3-hour stream growth",
|
|
28
|
-
unit: "By",
|
|
29
|
-
}),
|
|
30
21
|
};
|
|
31
22
|
}
|
|
32
23
|
return instruments;
|
|
@@ -40,15 +31,6 @@ function getInstruments() {
|
|
|
40
31
|
export function recordCancelledChild(args) {
|
|
41
32
|
getInstruments().cancelledChildren.add(1, { cause: args.cause });
|
|
42
33
|
}
|
|
43
|
-
/**
|
|
44
|
-
* Record one UI-part write. `bytes` is the serialised JSON size of the
|
|
45
|
-
* part as it lands on the stream; `kind` is the part `type` for
|
|
46
|
-
* dashboarding. The workflow body wraps its `DBOS.writeStream` call site
|
|
47
|
-
* so a single emitter records once per part.
|
|
48
|
-
*/
|
|
49
|
-
export function recordStreamWrite(args) {
|
|
50
|
-
getInstruments().streamBytes.add(args.bytes, { kind: args.kind });
|
|
51
|
-
}
|
|
52
34
|
/** Test hook — drop memoised instruments to rebind a fresh MeterProvider. */
|
|
53
35
|
export function __resetWorkflowMetricsForTest() {
|
|
54
36
|
instruments = undefined;
|
package/package.json
CHANGED