@inflexa-ai/harness 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/conversation-agent.js +23 -16
- package/dist/agents/sandbox/cheminformatics-agent.js +1 -1
- package/dist/agents/sandbox/chromatin-agent.js +1 -1
- package/dist/agents/sandbox/dna-methylation-agent.js +1 -1
- package/dist/agents/sandbox/drug-repurposing-agent.js +5 -10
- package/dist/agents/sandbox/enrichment-agent.js +1 -1
- package/dist/agents/sandbox/genomic-variant-agent.js +1 -11
- package/dist/agents/sandbox/immune-profiling-agent.js +1 -1
- package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -1
- package/dist/agents/sandbox/network-agent.js +1 -1
- package/dist/agents/sandbox/pkpd-clinical-response-agent.js +3 -6
- package/dist/agents/sandbox/scientific-executor.js +1 -1
- package/dist/agents/sandbox/shared.js +7 -13
- package/dist/agents/sandbox/translational-safety-agent.js +4 -7
- package/dist/agents/sandbox/types.d.ts +5 -5
- package/dist/agents/sandbox/types.js +4 -4
- package/dist/app/chat-turn.js +4 -1
- package/dist/app/message-assembly.d.ts +5 -2
- package/dist/app/message-assembly.js +7 -2
- package/dist/app/run-activity.d.ts +13 -0
- package/dist/app/run-activity.js +51 -0
- package/dist/contracts/target-dossier.d.ts +10 -10
- package/dist/execution/report-runner.js +1 -1
- package/dist/execution/run-event-parts.d.ts +44 -0
- package/dist/execution/run-event-parts.js +0 -0
- package/dist/execution/run-event-stream.d.ts +91 -0
- package/dist/execution/run-event-stream.js +212 -0
- package/dist/execution/run-synthesis.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +20 -1
- package/dist/input-sanitization.d.ts +14 -4
- package/dist/input-sanitization.js +27 -4
- package/dist/lib/storage.js +4 -1
- package/dist/loop/budget-exceeded.d.ts +18 -2
- package/dist/loop/budget-exceeded.js +49 -2
- package/dist/loop/run-agent.d.ts +22 -1
- package/dist/loop/run-agent.js +91 -25
- package/dist/loop/run-to-terminal.d.ts +22 -4
- package/dist/loop/run-to-terminal.js +20 -4
- package/dist/memory/ai-sdk-message-storage.d.ts +37 -4
- package/dist/memory/ai-sdk-message-storage.js +45 -4
- package/dist/memory/content-to-cortex.js +19 -3
- package/dist/memory/message-backfill.js +1 -1
- package/dist/memory/thread-history.js +34 -8
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +15 -6
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +16 -14
- package/dist/prompts/planner.js +15 -20
- package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.js +31 -22
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -10
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +8 -7
- package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.js +20 -14
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +7 -7
- package/dist/providers/errors.d.ts +7 -0
- package/dist/providers/errors.js +88 -3
- package/dist/runtime/pools.d.ts +15 -1
- package/dist/runtime/pools.js +15 -1
- package/dist/sandbox/sandbox-step-translate.d.ts +15 -2
- package/dist/sandbox/sandbox-step-translate.js +17 -2
- package/dist/schemas/workflow-state.d.ts +2 -2
- package/dist/schemas/workflow-state.js +2 -2
- package/dist/state/data-profile.d.ts +53 -1
- package/dist/state/data-profile.js +66 -6
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/state/init.js +37 -2
- package/dist/state/runs.d.ts +39 -0
- package/dist/state/runs.js +103 -0
- package/dist/tasks/data-profile-activity.d.ts +87 -0
- package/dist/tasks/data-profile-activity.js +56 -0
- package/dist/tasks/data-profile.js +66 -4
- package/dist/tools/bio/chembl.d.ts +1 -1
- package/dist/tools/bio/chembl.js +51 -54
- package/dist/tools/bio/comptox.js +35 -33
- package/dist/tools/bio/drug-gene-interactions.d.ts +76 -0
- package/dist/tools/bio/drug-gene-interactions.js +175 -0
- package/dist/tools/bio/gene-disease-evidence.d.ts +61 -0
- package/dist/tools/bio/gene-disease-evidence.js +187 -0
- package/dist/tools/bio/gene-preclinical-profile.d.ts +58 -0
- package/dist/tools/bio/gene-preclinical-profile.js +125 -0
- package/dist/tools/bio/index.d.ts +23 -16
- package/dist/tools/bio/index.js +29 -22
- package/dist/tools/bio/keys.d.ts +16 -9
- package/dist/tools/bio/keys.js +22 -13
- package/dist/tools/bio/lookup-annotation.d.ts +48 -0
- package/dist/tools/bio/lookup-annotation.js +173 -0
- package/dist/tools/bio/opentargets.d.ts +7 -11
- package/dist/tools/bio/opentargets.js +24 -35
- package/dist/tools/bio/pubchem.js +6 -4
- package/dist/tools/bio/pubmed.d.ts +15 -7
- package/dist/tools/bio/pubmed.js +79 -20
- package/dist/tools/bio/search-clinical-trials.d.ts +20 -1
- package/dist/tools/bio/search-clinical-trials.js +26 -5
- package/dist/tools/bio/search-faers.js +9 -3
- package/dist/tools/bio/search-gene.d.ts +0 -1
- package/dist/tools/bio/search-gene.js +4 -5
- package/dist/tools/bio/search-geo-datasets.js +10 -4
- package/dist/tools/bio/search-interactions.d.ts +8 -4
- package/dist/tools/bio/search-interactions.js +28 -12
- package/dist/tools/bio/target-safety.d.ts +59 -0
- package/dist/tools/bio/target-safety.js +191 -0
- package/dist/tools/execute-analysis.d.ts +1 -0
- package/dist/tools/execute-analysis.js +5 -5
- package/dist/tools/lib/dgidb-client.d.ts +49 -0
- package/dist/tools/lib/dgidb-client.js +168 -0
- package/dist/tools/lib/disgenet-client.d.ts +56 -0
- package/dist/tools/lib/disgenet-client.js +59 -0
- package/dist/tools/lib/drugbank-client.d.ts +83 -0
- package/dist/tools/lib/drugbank-client.js +95 -0
- package/dist/tools/lib/ensembl-client.d.ts +4 -1
- package/dist/tools/lib/ensembl-client.js +5 -3
- package/dist/tools/lib/gwas-catalog-client.d.ts +60 -0
- package/dist/tools/lib/gwas-catalog-client.js +136 -0
- package/dist/tools/lib/pharmgkb-client.d.ts +22 -0
- package/dist/tools/lib/pharmgkb-client.js +32 -0
- package/dist/tools/lib/pubmed-client.d.ts +57 -4
- package/dist/tools/lib/pubmed-client.js +57 -3
- package/dist/tools/lib/string-client.d.ts +25 -4
- package/dist/tools/lib/string-client.js +22 -5
- package/dist/tools/research/generate-plan.d.ts +16 -9
- package/dist/tools/research/generate-plan.js +405 -85
- package/dist/tools/research/inspect-data-profile.d.ts +31 -6
- package/dist/tools/research/inspect-data-profile.js +23 -7
- package/dist/tools/research/inspect-run.d.ts +33 -15
- package/dist/tools/research/inspect-run.js +155 -66
- package/dist/tools/research/literature-reviewer.js +7 -11
- package/dist/workflows/execute-analysis.d.ts +63 -0
- package/dist/workflows/execute-analysis.js +73 -2
- package/dist/workflows/sandbox-step.js +1 -11
- package/dist/workflows/target-assessment/decisions/index.d.ts +1 -1
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +1 -1
- package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +2 -2
- package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
- package/package.json +1 -1
- package/dist/tools/bio/check-safety-panel.d.ts +0 -35
- package/dist/tools/bio/check-safety-panel.js +0 -76
- package/dist/tools/bio/get-impc-ko-profile.d.ts +0 -9
- package/dist/tools/bio/get-impc-ko-profile.js +0 -23
- package/dist/tools/bio/lookup-go-term.d.ts +0 -31
- package/dist/tools/bio/lookup-go-term.js +0 -116
- package/dist/tools/bio/search-bgee-expression.d.ts +0 -10
- package/dist/tools/bio/search-bgee-expression.js +0 -30
- package/dist/tools/bio/search-clinvar.d.ts +0 -13
- package/dist/tools/bio/search-clinvar.js +0 -28
- package/dist/tools/bio/search-dgidb.d.ts +0 -53
- package/dist/tools/bio/search-dgidb.js +0 -213
- package/dist/tools/bio/search-disgenet.d.ts +0 -30
- package/dist/tools/bio/search-disgenet.js +0 -84
- package/dist/tools/bio/search-drugbank.d.ts +0 -38
- package/dist/tools/bio/search-drugbank.js +0 -109
- package/dist/tools/bio/search-gwas-catalog.d.ts +0 -33
- package/dist/tools/bio/search-gwas-catalog.js +0 -172
- package/dist/tools/bio/search-pathway.d.ts +0 -12
- package/dist/tools/bio/search-pathway.js +0 -32
- package/dist/tools/bio/search-pharmgkb.d.ts +0 -17
- package/dist/tools/bio/search-pharmgkb.js +0 -52
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two input-only rules of the run-event read seam: narrowing a raw durable
|
|
3
|
+
* stream value to the chat data-part contract, and the latest-wins reconciling
|
|
4
|
+
* fold applied before delivery.
|
|
5
|
+
*
|
|
6
|
+
* They live apart from the reader because they are the only part of the seam
|
|
7
|
+
* whose correctness is a function of its argument alone — no database, no
|
|
8
|
+
* durability engine, no clock — and keeping them here is what lets that be
|
|
9
|
+
* proven directly instead of inferred from a driven subscription.
|
|
10
|
+
*/
|
|
11
|
+
import type { CortexChatPart } from "../contracts/chat-parts.js";
|
|
12
|
+
/**
|
|
13
|
+
* Narrow one raw stream value to a chat data part, or `null` when it is not one.
|
|
14
|
+
*
|
|
15
|
+
* A run's streams carry more than the contract: a sandbox step also writes its
|
|
16
|
+
* loop orchestration under envelopes (`data-loop-event`, `data-sandbox-event`)
|
|
17
|
+
* that belong to the workflow layer and have no registry entry. The seam's
|
|
18
|
+
* contract is the `CortexChatPart` union, so anything the registry does not
|
|
19
|
+
* classify is not deliverable — which also covers a part written by a producer
|
|
20
|
+
* newer than the reader.
|
|
21
|
+
*
|
|
22
|
+
* The registry is keyed by the discriminant alone, so this validates `type` and
|
|
23
|
+
* trusts the producer for the payload. There is no per-part schema to validate
|
|
24
|
+
* against: `contracts/chat-parts.ts` declares interfaces, not Zod objects. Both
|
|
25
|
+
* ends of this stream are typed against that same union inside this package, so
|
|
26
|
+
* the discriminant is the boundary that can actually drift (a producer emitting
|
|
27
|
+
* something else entirely) and the one worth checking.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseRunEventPart(value: unknown): CortexChatPart | null;
|
|
30
|
+
/**
|
|
31
|
+
* Collapse a batch of parts so each reconciling id appears once, carrying its
|
|
32
|
+
* latest value, and every other part survives untouched in write order.
|
|
33
|
+
*
|
|
34
|
+
* `reconciling` in `PART_REGISTRY` already means "a later emission under this id
|
|
35
|
+
* supersedes the earlier one" — `data-step-activity` mints a stable id per
|
|
36
|
+
* `(runId, stepId)` precisely so a fold collapses every phase transition. Reading
|
|
37
|
+
* the flag rather than holding a list of part types here keeps one source of
|
|
38
|
+
* truth: a part type added to the registry later folds correctly without this
|
|
39
|
+
* module changing.
|
|
40
|
+
*
|
|
41
|
+
* A survivor keeps the position of its LAST occurrence, so relative order among
|
|
42
|
+
* the parts that are delivered is the order they were written.
|
|
43
|
+
*/
|
|
44
|
+
export declare function foldRunEventParts(parts: readonly CortexChatPart[]): CortexChatPart[];
|
|
Binary file
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunEventStream — the read side of the durable per-run event stream.
|
|
3
|
+
*
|
|
4
|
+
* Every analysis run already writes a rich, typed event stream: the parent
|
|
5
|
+
* `executeAnalysis` workflow writes the run-level parts, and each `sandbox-step`
|
|
6
|
+
* child writes its own step-level parts to its OWN stream, because a workflow can
|
|
7
|
+
* only write the stream it owns. This seam reads all of them back as one channel,
|
|
8
|
+
* so an embedder can show a user what a run is actually doing without
|
|
9
|
+
* reconstructing progress from the durability engine's internal step-cache tables
|
|
10
|
+
* — which record a step only when it *completes*, and so describe the thing that
|
|
11
|
+
* just finished rather than the thing in flight.
|
|
12
|
+
*
|
|
13
|
+
* The seam quarantines the engine the same way `RunLauncher` does for starting
|
|
14
|
+
* workflows: the delivered values are the harness's own `contracts/` part types,
|
|
15
|
+
* and no engine type appears in the exported signature.
|
|
16
|
+
*
|
|
17
|
+
* It is a push subscription rather than a snapshot reader because the engine's
|
|
18
|
+
* read primitive is an async generator with no offset parameter — "give me what
|
|
19
|
+
* is new since X" is not expressible on the public API, and expressing it would
|
|
20
|
+
* mean reaching into the SDK internals this seam exists to remove. A merged async
|
|
21
|
+
* iterator was the other candidate and was rejected because the set of sources
|
|
22
|
+
* grows during iteration (children join mid-run), which costs materially more
|
|
23
|
+
* code than invoking a handler; a callback also matches the `EmitFn` idiom the
|
|
24
|
+
* producers already speak.
|
|
25
|
+
*
|
|
26
|
+
* `subscribe` resolves rather than returning a `Result` because it has no failure
|
|
27
|
+
* to report. Observation is a diagnostic channel: a throwing handler and a
|
|
28
|
+
* failing stream are both logged and contained, mirroring the `safeEmit` stance
|
|
29
|
+
* on the write side, where a dropped UI frame must never fail a step. A read side
|
|
30
|
+
* that tore itself down on one bad part would be strictly less robust than the
|
|
31
|
+
* writer it observes.
|
|
32
|
+
*/
|
|
33
|
+
import type { Pool } from "pg";
|
|
34
|
+
import type { CortexChatPart } from "../contracts/chat-parts.js";
|
|
35
|
+
import type { Logger } from "../lib/logger.js";
|
|
36
|
+
/**
|
|
37
|
+
* Construction-time dependencies. The pool is the application pool the run
|
|
38
|
+
* ledger lives in; the logger is where every contained failure is reported, and
|
|
39
|
+
* defaults to silence so an embedder that wired none sees nothing rather than
|
|
40
|
+
* having console forced on it.
|
|
41
|
+
*/
|
|
42
|
+
export interface RunEventStreamDeps {
|
|
43
|
+
readonly pool: Pool;
|
|
44
|
+
readonly logger?: Logger;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Receives one part. May be async — the subscription awaits it, so a handler is
|
|
48
|
+
* never invoked concurrently with itself and back-pressure is the caller's to
|
|
49
|
+
* exert by taking its time. A throw is logged and swallowed.
|
|
50
|
+
*/
|
|
51
|
+
export type RunEventPartHandler = (part: CortexChatPart) => void | Promise<void>;
|
|
52
|
+
/** Call-time parameters of one subscription. */
|
|
53
|
+
export interface RunEventSubscribeOptions {
|
|
54
|
+
/** The run to observe. Also the parent workflow's id. */
|
|
55
|
+
readonly runId: string;
|
|
56
|
+
/** Where each delivered part goes. */
|
|
57
|
+
readonly onPart: RunEventPartHandler;
|
|
58
|
+
/** Aborting stops delivery and settles the returned promise. */
|
|
59
|
+
readonly signal: AbortSignal;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The run-event read seam. One method, because a run-scoped subscription is the
|
|
63
|
+
* whole capability: a general-purpose stream reader would leak the engine's model
|
|
64
|
+
* back into the surface this exists to quarantine.
|
|
65
|
+
*/
|
|
66
|
+
export interface RunEventStream {
|
|
67
|
+
/**
|
|
68
|
+
* Deliver every part the run produces — parent and children alike — to
|
|
69
|
+
* `onPart`, resolving when the run is terminal and every stream opened has
|
|
70
|
+
* drained, or promptly when the signal aborts.
|
|
71
|
+
*
|
|
72
|
+
* Parts from one workflow arrive in the order that workflow wrote them. No
|
|
73
|
+
* order is promised ACROSS the parent and its children: those workflows
|
|
74
|
+
* execute concurrently and the producers establish no cross-stream clock, so
|
|
75
|
+
* a total order could only be synthesised from timestamps that were never
|
|
76
|
+
* meant to be one. Consumers do not need it — every part is addressed by
|
|
77
|
+
* `runId`/`stepId` and the reconciling fold is per id.
|
|
78
|
+
*/
|
|
79
|
+
subscribe(options: RunEventSubscribeOptions): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build the DBOS-backed realization of {@link RunEventStream}.
|
|
83
|
+
*
|
|
84
|
+
* Every stream is read from its beginning, and each workflow is opened at most
|
|
85
|
+
* once per subscription. Replay is not a cost worked around here, it is what
|
|
86
|
+
* makes attaching mid-run correct: a subscriber joining at minute ten receives
|
|
87
|
+
* the run's whole history and folding it yields the true current state.
|
|
88
|
+
* Re-opening a workflow would redeliver that history and duplicate every part
|
|
89
|
+
* the fold does not reconcile.
|
|
90
|
+
*/
|
|
91
|
+
export declare function createRunEventStream(deps: RunEventStreamDeps): RunEventStream;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunEventStream — the read side of the durable per-run event stream.
|
|
3
|
+
*
|
|
4
|
+
* Every analysis run already writes a rich, typed event stream: the parent
|
|
5
|
+
* `executeAnalysis` workflow writes the run-level parts, and each `sandbox-step`
|
|
6
|
+
* child writes its own step-level parts to its OWN stream, because a workflow can
|
|
7
|
+
* only write the stream it owns. This seam reads all of them back as one channel,
|
|
8
|
+
* so an embedder can show a user what a run is actually doing without
|
|
9
|
+
* reconstructing progress from the durability engine's internal step-cache tables
|
|
10
|
+
* — which record a step only when it *completes*, and so describe the thing that
|
|
11
|
+
* just finished rather than the thing in flight.
|
|
12
|
+
*
|
|
13
|
+
* The seam quarantines the engine the same way `RunLauncher` does for starting
|
|
14
|
+
* workflows: the delivered values are the harness's own `contracts/` part types,
|
|
15
|
+
* and no engine type appears in the exported signature.
|
|
16
|
+
*
|
|
17
|
+
* It is a push subscription rather than a snapshot reader because the engine's
|
|
18
|
+
* read primitive is an async generator with no offset parameter — "give me what
|
|
19
|
+
* is new since X" is not expressible on the public API, and expressing it would
|
|
20
|
+
* mean reaching into the SDK internals this seam exists to remove. A merged async
|
|
21
|
+
* iterator was the other candidate and was rejected because the set of sources
|
|
22
|
+
* grows during iteration (children join mid-run), which costs materially more
|
|
23
|
+
* code than invoking a handler; a callback also matches the `EmitFn` idiom the
|
|
24
|
+
* producers already speak.
|
|
25
|
+
*
|
|
26
|
+
* `subscribe` resolves rather than returning a `Result` because it has no failure
|
|
27
|
+
* to report. Observation is a diagnostic channel: a throwing handler and a
|
|
28
|
+
* failing stream are both logged and contained, mirroring the `safeEmit` stance
|
|
29
|
+
* on the write side, where a dropped UI frame must never fail a step. A read side
|
|
30
|
+
* that tore itself down on one bad part would be strictly less robust than the
|
|
31
|
+
* writer it observes.
|
|
32
|
+
*/
|
|
33
|
+
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
34
|
+
import { sleep } from "../lib/async-utils.js";
|
|
35
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
36
|
+
import { describeDbError } from "../lib/db-result.js";
|
|
37
|
+
import { queryStepsByRun } from "../state/step-executions.js";
|
|
38
|
+
import { foldRunEventParts, parseRunEventPart } from "./run-event-parts.js";
|
|
39
|
+
/** Durable stream key every run producer writes its parts under. */
|
|
40
|
+
const RUN_EVENT_STREAM_KEY = "events";
|
|
41
|
+
/**
|
|
42
|
+
* How often the run's step ledger is re-read for children that have started
|
|
43
|
+
* since the last look.
|
|
44
|
+
*
|
|
45
|
+
* A child records its own workflow id as it begins, so discovery can only be as
|
|
46
|
+
* timely as that write and there is nothing to subscribe to before it lands.
|
|
47
|
+
* Polling is the honest mechanism: the parent's dag snapshot marks a step running
|
|
48
|
+
* before the child's body has written its row, so an event-driven re-check would
|
|
49
|
+
* race the ledger. One second matches the engine's own stream-polling fallback,
|
|
50
|
+
* and lagging a child's first writes costs nothing — a stream subscribed late is
|
|
51
|
+
* still read from its beginning.
|
|
52
|
+
*/
|
|
53
|
+
const CHILD_DISCOVERY_INTERVAL_MS = 1_000;
|
|
54
|
+
/**
|
|
55
|
+
* Build the DBOS-backed realization of {@link RunEventStream}.
|
|
56
|
+
*
|
|
57
|
+
* Every stream is read from its beginning, and each workflow is opened at most
|
|
58
|
+
* once per subscription. Replay is not a cost worked around here, it is what
|
|
59
|
+
* makes attaching mid-run correct: a subscriber joining at minute ten receives
|
|
60
|
+
* the run's whole history and folding it yields the true current state.
|
|
61
|
+
* Re-opening a workflow would redeliver that history and duplicate every part
|
|
62
|
+
* the fold does not reconcile.
|
|
63
|
+
*/
|
|
64
|
+
export function createRunEventStream(deps) {
|
|
65
|
+
const baseLogger = (deps.logger ?? createNoopLogger()).named("run-event-stream");
|
|
66
|
+
return {
|
|
67
|
+
async subscribe({ runId, onPart, signal }) {
|
|
68
|
+
const logger = baseLogger.with({ runId });
|
|
69
|
+
// Open generators, so an abort can ask each to wind down. The engine's
|
|
70
|
+
// reader exposes no cancellation: while it waits on a quiet-but-active
|
|
71
|
+
// workflow it is suspended inside an await, where a queued `return()` is
|
|
72
|
+
// only honoured once it next reaches a yield. Teardown is therefore
|
|
73
|
+
// best-effort by construction, and the subscription's own promise is
|
|
74
|
+
// raced against the signal rather than waiting on it.
|
|
75
|
+
const open = new Set();
|
|
76
|
+
let resolveAbort;
|
|
77
|
+
const aborted = new Promise((resolve) => {
|
|
78
|
+
resolveAbort = resolve;
|
|
79
|
+
});
|
|
80
|
+
const onAbort = () => {
|
|
81
|
+
resolveAbort();
|
|
82
|
+
for (const generator of open) {
|
|
83
|
+
void generator.return(undefined).catch(() => {
|
|
84
|
+
/* the read is already being abandoned */
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
if (signal.aborted)
|
|
89
|
+
onAbort();
|
|
90
|
+
else
|
|
91
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
92
|
+
// Delivery is serialized through one queue across every stream. That
|
|
93
|
+
// gives the fold a batch to collapse — whatever accumulated while the
|
|
94
|
+
// previous handler call was in flight is reconciled before it is handed
|
|
95
|
+
// over, which is what spares a mid-run subscriber the superseded
|
|
96
|
+
// intermediates it replayed. The queue is FIFO and the fold keeps
|
|
97
|
+
// survivors in place, so a single stream's write order survives it.
|
|
98
|
+
const pending = [];
|
|
99
|
+
let delivering = false;
|
|
100
|
+
const deliver = async () => {
|
|
101
|
+
if (delivering)
|
|
102
|
+
return;
|
|
103
|
+
delivering = true;
|
|
104
|
+
try {
|
|
105
|
+
while (pending.length > 0 && !signal.aborted) {
|
|
106
|
+
const batch = pending.splice(0, pending.length);
|
|
107
|
+
for (const part of foldRunEventParts(batch)) {
|
|
108
|
+
if (signal.aborted)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
await onPart(part);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
logger.warn("run-event handler threw", { partType: part.type, ...logger.errorFields(err) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
delivering = false;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const readWorkflowStream = async (workflowId) => {
|
|
124
|
+
const streamLogger = logger.with({ workflowId });
|
|
125
|
+
const generator = DBOS.readStream(workflowId, RUN_EVENT_STREAM_KEY);
|
|
126
|
+
open.add(generator);
|
|
127
|
+
try {
|
|
128
|
+
for (;;) {
|
|
129
|
+
const next = await generator.next();
|
|
130
|
+
// Checked after the read, not before: `next()` is what notices
|
|
131
|
+
// the workflow going inactive, and an aborted subscription
|
|
132
|
+
// must not enqueue anything the drain will never take.
|
|
133
|
+
if (next.done === true || signal.aborted)
|
|
134
|
+
return;
|
|
135
|
+
const part = parseRunEventPart(next.value);
|
|
136
|
+
if (part === null) {
|
|
137
|
+
streamLogger.debug("dropped a stream value outside the chat-part contract");
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
pending.push(part);
|
|
141
|
+
void deliver();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
// One stream's failure is contained here so the parent and every
|
|
146
|
+
// other child keep arriving.
|
|
147
|
+
streamLogger.error("run-event stream read failed", streamLogger.errorFields(err));
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
open.delete(generator);
|
|
151
|
+
void generator.return(undefined).catch(() => {
|
|
152
|
+
/* already finished, or abandoned under abort */
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const subscribed = new Set([runId]);
|
|
157
|
+
const childReads = [];
|
|
158
|
+
// Children are addressed by the id the workflow bodies already persist.
|
|
159
|
+
// Deriving them from the scheduler's `${runId}-${idx}` naming would
|
|
160
|
+
// hard-code a scheme that belongs to the scheduler, and the engine's own
|
|
161
|
+
// child bookkeeping is internals of the kind this seam removes.
|
|
162
|
+
const discoverChildren = async () => {
|
|
163
|
+
(await queryStepsByRun(deps.pool, runId)).match((rows) => {
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
const childWorkflowId = row.childWorkflowId;
|
|
166
|
+
if (childWorkflowId === null || subscribed.has(childWorkflowId))
|
|
167
|
+
continue;
|
|
168
|
+
subscribed.add(childWorkflowId);
|
|
169
|
+
childReads.push(readWorkflowStream(childWorkflowId));
|
|
170
|
+
}
|
|
171
|
+
}, (error) => {
|
|
172
|
+
logger.warn("child discovery failed", { err: describeDbError(error) });
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
let parentDrained = false;
|
|
176
|
+
const parentRead = readWorkflowStream(runId).finally(() => {
|
|
177
|
+
parentDrained = true;
|
|
178
|
+
});
|
|
179
|
+
// The parent's stream draining IS the run reaching terminal: the engine
|
|
180
|
+
// ends a read exactly when the workflow is no longer active. One further
|
|
181
|
+
// pass runs after that — a child whose id landed in the ledger late is
|
|
182
|
+
// still replayed from zero — and then discovery stops.
|
|
183
|
+
const discovery = (async () => {
|
|
184
|
+
for (;;) {
|
|
185
|
+
if (signal.aborted)
|
|
186
|
+
return;
|
|
187
|
+
await discoverChildren();
|
|
188
|
+
if (signal.aborted || parentDrained)
|
|
189
|
+
return;
|
|
190
|
+
await Promise.race([sleep(CHILD_DISCOVERY_INTERVAL_MS), aborted, parentRead]);
|
|
191
|
+
}
|
|
192
|
+
})();
|
|
193
|
+
const settled = (async () => {
|
|
194
|
+
await discovery;
|
|
195
|
+
await parentRead;
|
|
196
|
+
await Promise.all(childReads);
|
|
197
|
+
})().catch((err) => {
|
|
198
|
+
// Defensive: every stream read and every discovery pass contains its
|
|
199
|
+
// own failure, so arriving here is a defect in this seam rather than
|
|
200
|
+
// a runtime condition. Logged rather than left to surface as an
|
|
201
|
+
// unhandled rejection after an abort already settled the caller.
|
|
202
|
+
logger.error("run-event subscription failed", logger.errorFields(err));
|
|
203
|
+
});
|
|
204
|
+
try {
|
|
205
|
+
await Promise.race([settled, aborted]);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
signal.removeEventListener("abort", onAbort);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -369,9 +369,9 @@ export async function generateRunSynthesis(input) {
|
|
|
369
369
|
signal,
|
|
370
370
|
emit,
|
|
371
371
|
runStep: passthroughStep,
|
|
372
|
+
resolved: () => holder.outcome !== null,
|
|
372
373
|
};
|
|
373
374
|
await runToTerminal(agent, [{ role: "user", content: prompt }], input.session, loopDeps, {
|
|
374
|
-
resolved: () => holder.outcome !== null,
|
|
375
375
|
tools: [submitTool, blockerTool],
|
|
376
376
|
nudge: "You ended without calling a terminal tool. You MUST call " +
|
|
377
377
|
"submit_synthesis with the final synthesis now, or report_blocker " +
|
package/dist/index.d.ts
CHANGED
|
@@ -54,6 +54,7 @@ export { createThreadStore } from "./memory/thread-store.js";
|
|
|
54
54
|
export type { ThreadStore, Thread, CreateThreadInput, ListThreadsInput, ThreadPage } from "./memory/thread-store.js";
|
|
55
55
|
export { createThreadHistory } from "./memory/thread-history.js";
|
|
56
56
|
export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome } from "./memory/thread-history.js";
|
|
57
|
+
export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
|
|
57
58
|
export { contentToCortexMessages } from "./memory/content-to-cortex.js";
|
|
58
59
|
export { createCardResolver } from "./memory/reconstruct-cards.js";
|
|
59
60
|
export type { ToolCardResolver, StoredToolCallForCard } from "./memory/reconstruct-cards.js";
|
|
@@ -88,9 +89,11 @@ export { deliverExecEvent, execEventTopic } from "./sandbox/deliver-exec-event.j
|
|
|
88
89
|
export { ExecEventMessageSchema } from "./sandbox/types.js";
|
|
89
90
|
export type { ExecEventMessage, DoneMarker } from "./sandbox/types.js";
|
|
90
91
|
export { registerExecuteAnalysis } from "./workflows/execute-analysis.js";
|
|
91
|
-
export type { ExecuteAnalysisDeps, ExecuteAnalysisInput, ExecuteAnalysisResult, RunProvenanceEvent } from "./workflows/execute-analysis.js";
|
|
92
|
+
export type { ExecuteAnalysisDeps, ExecuteAnalysisInput, ExecuteAnalysisResult, RunObservation, RunProvenanceEvent, RunStepObservation, StepRuntimeStatus, } from "./workflows/execute-analysis.js";
|
|
92
93
|
export { registerSandboxStep } from "./workflows/sandbox-step.js";
|
|
93
94
|
export type { SandboxStepDeps, SandboxStepInput, SandboxStepResult, SandboxAgentBuildContext } from "./workflows/sandbox-step.js";
|
|
95
|
+
export { createRunEventStream } from "./execution/run-event-stream.js";
|
|
96
|
+
export type { RunEventStream, RunEventStreamDeps, RunEventSubscribeOptions, RunEventPartHandler } from "./execution/run-event-stream.js";
|
|
94
97
|
export { createSandboxAgents, SANDBOX_AGENT_META } from "./agents/sandbox/index.js";
|
|
95
98
|
export type { SandboxAgentDeps, SandboxStepCoords, AgentMeta } from "./agents/sandbox/index.js";
|
|
96
99
|
export { AnalysisPlanSchema } from "./schemas/workflow-state.js";
|
|
@@ -101,7 +104,7 @@ export type { ValidatePlanOptions } from "./schemas/validate-plan.js";
|
|
|
101
104
|
export type { ValidationResult } from "./schemas/validate-plan.js";
|
|
102
105
|
export { upsertPlan, loadPlan } from "./state/plans.js";
|
|
103
106
|
export type { UpsertPlanInput } from "./state/plans.js";
|
|
104
|
-
export { insertRun, reserveRunById, queryActiveRun, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
|
|
107
|
+
export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
|
|
105
108
|
export type { InsertRunInput, RunReservation } from "./state/runs.js";
|
|
106
109
|
export { queryStepsByRun } from "./state/step-executions.js";
|
|
107
110
|
export type { CortexRunRow, StepExecutionRow, RunStatus } from "./state/schema.js";
|
package/dist/index.js
CHANGED
|
@@ -81,6 +81,16 @@ export { createStreamingChat } from "./providers/streaming-chat.js";
|
|
|
81
81
|
export { prepareChatTurn } from "./app/chat-turn.js";
|
|
82
82
|
export { createThreadStore } from "./memory/thread-store.js";
|
|
83
83
|
export { createThreadHistory } from "./memory/thread-history.js";
|
|
84
|
+
// The synthetic-message primitives. A host appends a record of out-of-band work (an analysis run's
|
|
85
|
+
// outcome) into a thread through `appendTurn`, and needs a message the model reads but that is not
|
|
86
|
+
// user input — which is exactly what the marker denotes. The constructor is exported rather than
|
|
87
|
+
// the marker key so a host can never hand-assemble a message that the turn-boundary predicates
|
|
88
|
+
// (`isGenuineUserStart` and its SQL twin) would then fail to recognise; `isSyntheticUserMessage`
|
|
89
|
+
// lets a host renderer tell such a message apart from a genuine user turn on the way back out.
|
|
90
|
+
// `syntheticRecordMessage` is the one a HOST reaches for: equally non-turn-opening, but rendered —
|
|
91
|
+
// the display reconstruction emits it as a `system` message rather than dropping it as loop
|
|
92
|
+
// machinery, so an appended run outcome is visible in the transcript instead of silently absent.
|
|
93
|
+
export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
|
|
84
94
|
export { contentToCortexMessages } from "./memory/content-to-cortex.js";
|
|
85
95
|
export { createCardResolver } from "./memory/reconstruct-cards.js";
|
|
86
96
|
export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./contracts/part-registry.js";
|
|
@@ -138,6 +148,15 @@ export { ExecEventMessageSchema } from "./sandbox/types.js";
|
|
|
138
148
|
// these, but an embedder running only the run engine registers them directly.
|
|
139
149
|
export { registerExecuteAnalysis } from "./workflows/execute-analysis.js";
|
|
140
150
|
export { registerSandboxStep } from "./workflows/sandbox-step.js";
|
|
151
|
+
// Run-event read seam — the fine-grained durable channel beside `observeRun`.
|
|
152
|
+
// The two are a deliberate pair, not a duplication: `observeRun` is the coarse,
|
|
153
|
+
// in-process run/step snapshot a workflow hands its host synchronously, and it
|
|
154
|
+
// defers sub-step detail to the run's durable event stream. This is that stream,
|
|
155
|
+
// read back — the per-tool-call activity, file trees, and step summaries a run
|
|
156
|
+
// writes, fanned in from the parent workflow and every sandbox-step child.
|
|
157
|
+
// Nothing in the signature belongs to the durability engine, so an embedder
|
|
158
|
+
// consumes run events without depending on it.
|
|
159
|
+
export { createRunEventStream } from "./execution/run-event-stream.js";
|
|
141
160
|
// Sandbox-agent catalog. `buildAgent` maps a `SandboxAgentBuildContext` onto
|
|
142
161
|
// `SandboxAgentDeps`, then selects the per-step agent by id from this record;
|
|
143
162
|
// `SANDBOX_AGENT_META` is the planner-facing meta the same ids key.
|
|
@@ -155,7 +174,7 @@ export { validatePlan } from "./schemas/validate-plan.js";
|
|
|
155
174
|
// caller-derived id and inserts-if-absent (the deterministic-id intake path);
|
|
156
175
|
// the run-state calls mirror the chat trigger's reserve → dedup → status flow.
|
|
157
176
|
export { upsertPlan, loadPlan } from "./state/plans.js";
|
|
158
|
-
export { insertRun, reserveRunById, queryActiveRun, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
|
|
177
|
+
export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
|
|
159
178
|
export { queryStepsByRun } from "./state/step-executions.js";
|
|
160
179
|
// Host-neutral reference-data catalog and shared installation receipts. Each
|
|
161
180
|
// artifact names the third-party upstream it is fetched from; embedders own all
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Input sanitization —
|
|
3
|
-
*
|
|
4
|
-
* assistant messages or tool results.
|
|
2
|
+
* Input sanitization — pure, always-on text functions, not a plug-in pipeline.
|
|
3
|
+
* What runs is exactly what is declared here.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
5
|
+
* `normalizeUnicode` and `redactSecrets` are applied once to the incoming user
|
|
6
|
+
* message by the chat route (change 6), and never to assistant messages or tool
|
|
7
|
+
* results. `stripNulCharacters` is the exception — it applies to tool results.
|
|
7
8
|
*/
|
|
8
9
|
/**
|
|
9
10
|
* NFC-normalize and strip control characters.
|
|
@@ -13,6 +14,15 @@
|
|
|
13
14
|
* that could smuggle formatting or terminal escapes.
|
|
14
15
|
*/
|
|
15
16
|
export declare function normalizeUnicode(text: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Strip every NUL from `text`. Applied where a tool result is built — so the
|
|
19
|
+
* loop's in-memory message and the stored row agree byte-for-byte and the
|
|
20
|
+
* prompt-cache prefix survives the round trip — and again where the turn is
|
|
21
|
+
* written, which holds the invariant for writers that are not the loop. User
|
|
22
|
+
* input needs no separate treatment: `normalizeUnicode` already strips NUL along
|
|
23
|
+
* with the rest of the C0 range.
|
|
24
|
+
*/
|
|
25
|
+
export declare function stripNulCharacters(text: string): string;
|
|
16
26
|
/**
|
|
17
27
|
* Replace structured secret formats with `[REDACTED: <name>]`.
|
|
18
28
|
*
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Input sanitization —
|
|
3
|
-
*
|
|
4
|
-
* assistant messages or tool results.
|
|
2
|
+
* Input sanitization — pure, always-on text functions, not a plug-in pipeline.
|
|
3
|
+
* What runs is exactly what is declared here.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
5
|
+
* `normalizeUnicode` and `redactSecrets` are applied once to the incoming user
|
|
6
|
+
* message by the chat route (change 6), and never to assistant messages or tool
|
|
7
|
+
* results. `stripNulCharacters` is the exception — it applies to tool results.
|
|
7
8
|
*/
|
|
8
9
|
/**
|
|
9
10
|
* C0/C1 control characters and DEL, excluding `\t` (U+0009) and `\n`
|
|
@@ -21,6 +22,28 @@ const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
|
21
22
|
export function normalizeUnicode(text) {
|
|
22
23
|
return text.normalize("NFC").replace(CONTROL_CHARS, "");
|
|
23
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* NUL (U+0000) — the one character a stored message cannot carry. Postgres
|
|
27
|
+
* refuses it inside JSON: a `jsonb` column rejects the insert outright, and a
|
|
28
|
+
* `json` column accepts it but errors the moment a JSON operator reads that row
|
|
29
|
+
* ("U+0000 cannot be converted to text"), which is what `retractLastTurn`'s
|
|
30
|
+
* boundary predicate does. Either way one NUL byte poisons a thread, and it
|
|
31
|
+
* arrives through no fault of the model: tool results carry raw command output
|
|
32
|
+
* and file bytes, which is why they are otherwise left unsanitized.
|
|
33
|
+
*/
|
|
34
|
+
// eslint-disable-next-line no-control-regex -- matching NUL is the intent; see above.
|
|
35
|
+
const NUL = /\u0000/g;
|
|
36
|
+
/**
|
|
37
|
+
* Strip every NUL from `text`. Applied where a tool result is built — so the
|
|
38
|
+
* loop's in-memory message and the stored row agree byte-for-byte and the
|
|
39
|
+
* prompt-cache prefix survives the round trip — and again where the turn is
|
|
40
|
+
* written, which holds the invariant for writers that are not the loop. User
|
|
41
|
+
* input needs no separate treatment: `normalizeUnicode` already strips NUL along
|
|
42
|
+
* with the rest of the C0 range.
|
|
43
|
+
*/
|
|
44
|
+
export function stripNulCharacters(text) {
|
|
45
|
+
return text.replace(NUL, "");
|
|
46
|
+
}
|
|
24
47
|
/**
|
|
25
48
|
* Each entry redacts one structured, prefixed secret format. Every pattern
|
|
26
49
|
* has a prefix (`AKIA`, `sk-`, `gh*_`, `eyJ`, `Bearer`, a DB scheme) that
|
package/dist/lib/storage.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the composition root and threaded as a constructor dep (see the harness-durable-runtime spec).
|
|
8
8
|
*/
|
|
9
9
|
import pg from "pg";
|
|
10
|
-
import { resolveAppPoolSize } from "../runtime/pools.js";
|
|
10
|
+
import { APP_POOL_ACQUIRE_TIMEOUT_MS, resolveAppPoolSize } from "../runtime/pools.js";
|
|
11
11
|
function poolOptions(config) {
|
|
12
12
|
return {
|
|
13
13
|
host: config.host,
|
|
@@ -18,6 +18,9 @@ function poolOptions(config) {
|
|
|
18
18
|
// Per-pod app pool; DBOS owns its system-DB connections separately
|
|
19
19
|
// (see the postgres-storage-backend spec). The boot guard checks this fits `max_connections`.
|
|
20
20
|
max: resolveAppPoolSize(config.poolMax),
|
|
21
|
+
// Acquisition is bounded so a saturated pool fails a caller instead of
|
|
22
|
+
// hanging it — see `APP_POOL_ACQUIRE_TIMEOUT_MS` for the invariant.
|
|
23
|
+
connectionTimeoutMillis: APP_POOL_ACQUIRE_TIMEOUT_MS,
|
|
21
24
|
// `pg` accepts `ssl: false` to disable entirely, or an object with
|
|
22
25
|
// `rejectUnauthorized` to tune TLS. We map sslmode to those forms.
|
|
23
26
|
ssl: config.sslMode === "disable"
|
|
@@ -6,15 +6,31 @@
|
|
|
6
6
|
* is wasteful because every subsequent call charged to the same budget will
|
|
7
7
|
* also fail until the user tops up.
|
|
8
8
|
*
|
|
9
|
-
* Classification order (first hit wins):
|
|
9
|
+
* Classification order (first hit wins), and the order is load-bearing:
|
|
10
10
|
* 1. Structured `statusCode === 402` (or `status === 402`) on any
|
|
11
11
|
* link of the cause chain. The gateway emits 402 exclusively for
|
|
12
12
|
* `budget_exceeded`, so a structured 402 is unambiguous.
|
|
13
|
-
* 2.
|
|
13
|
+
* 2. A `ProviderError` on the value or its cause chain that was decided on
|
|
14
|
+
* a status: its `type` answers, and the text patterns are skipped.
|
|
15
|
+
* 3. Pattern fallback: `/budget.?exceeded/i` on the stringified
|
|
14
16
|
* top-level error message. Covers stringified throws and paths
|
|
15
17
|
* where no `statusCode` is attached (ops scripts, sandbox-server
|
|
16
18
|
* surfacing upstream billing errors as text).
|
|
17
19
|
*
|
|
20
|
+
* Step 2 exists because the text heuristic must never outrank a real
|
|
21
|
+
* classification. A `ProviderError` message carries an excerpt of the provider's
|
|
22
|
+
* response body, so a non-retryable 400 whose body merely mentions a budget would
|
|
23
|
+
* otherwise match step 3 — and the callers of this predicate act on it fatally
|
|
24
|
+
* (`sandbox-step` self-cancels the workflow, `execute-analysis` re-raises it as a
|
|
25
|
+
* budget throw). A phrase inside a quoted body is not evidence against the status
|
|
26
|
+
* the failure actually carried.
|
|
27
|
+
*
|
|
28
|
+
* "Decided on a status" is the load-bearing qualifier. `toProviderError` wraps
|
|
29
|
+
* every throwable, including ones `classifyProviderError` had no status for — it
|
|
30
|
+
* returns `provider` as a fall-through default there, which is an absence of
|
|
31
|
+
* classification, not a determination. Treating that as authoritative would
|
|
32
|
+
* silence step 3 in precisely the case it was written for.
|
|
33
|
+
*
|
|
18
34
|
* Returns `false` for all other errors. Safe to call with any value;
|
|
19
35
|
* non-Error inputs hit the stringified-pattern branch and return `false`
|
|
20
36
|
* unless the string happens to match.
|
|
@@ -6,19 +6,36 @@
|
|
|
6
6
|
* is wasteful because every subsequent call charged to the same budget will
|
|
7
7
|
* also fail until the user tops up.
|
|
8
8
|
*
|
|
9
|
-
* Classification order (first hit wins):
|
|
9
|
+
* Classification order (first hit wins), and the order is load-bearing:
|
|
10
10
|
* 1. Structured `statusCode === 402` (or `status === 402`) on any
|
|
11
11
|
* link of the cause chain. The gateway emits 402 exclusively for
|
|
12
12
|
* `budget_exceeded`, so a structured 402 is unambiguous.
|
|
13
|
-
* 2.
|
|
13
|
+
* 2. A `ProviderError` on the value or its cause chain that was decided on
|
|
14
|
+
* a status: its `type` answers, and the text patterns are skipped.
|
|
15
|
+
* 3. Pattern fallback: `/budget.?exceeded/i` on the stringified
|
|
14
16
|
* top-level error message. Covers stringified throws and paths
|
|
15
17
|
* where no `statusCode` is attached (ops scripts, sandbox-server
|
|
16
18
|
* surfacing upstream billing errors as text).
|
|
17
19
|
*
|
|
20
|
+
* Step 2 exists because the text heuristic must never outrank a real
|
|
21
|
+
* classification. A `ProviderError` message carries an excerpt of the provider's
|
|
22
|
+
* response body, so a non-retryable 400 whose body merely mentions a budget would
|
|
23
|
+
* otherwise match step 3 — and the callers of this predicate act on it fatally
|
|
24
|
+
* (`sandbox-step` self-cancels the workflow, `execute-analysis` re-raises it as a
|
|
25
|
+
* budget throw). A phrase inside a quoted body is not evidence against the status
|
|
26
|
+
* the failure actually carried.
|
|
27
|
+
*
|
|
28
|
+
* "Decided on a status" is the load-bearing qualifier. `toProviderError` wraps
|
|
29
|
+
* every throwable, including ones `classifyProviderError` had no status for — it
|
|
30
|
+
* returns `provider` as a fall-through default there, which is an absence of
|
|
31
|
+
* classification, not a determination. Treating that as authoritative would
|
|
32
|
+
* silence step 3 in precisely the case it was written for.
|
|
33
|
+
*
|
|
18
34
|
* Returns `false` for all other errors. Safe to call with any value;
|
|
19
35
|
* non-Error inputs hit the stringified-pattern branch and return `false`
|
|
20
36
|
* unless the string happens to match.
|
|
21
37
|
*/
|
|
38
|
+
import { extractStatus, isProviderError } from "../providers/errors.js";
|
|
22
39
|
const BUDGET_EXCEEDED_PATTERNS = [/budget.?exceeded/i];
|
|
23
40
|
/** Max depth walked on the cause chain looking for a structured statusCode. */
|
|
24
41
|
const MAX_CAUSE_HOPS = 5;
|
|
@@ -33,9 +50,39 @@ function hasStatus402(err) {
|
|
|
33
50
|
}
|
|
34
51
|
return false;
|
|
35
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Find a `ProviderError` on the value or its cause chain. A `ResultError`
|
|
55
|
+
* thrown at a step boundary carries the structured value on `.cause`, which is
|
|
56
|
+
* the shape the callers of this predicate actually catch.
|
|
57
|
+
*/
|
|
58
|
+
function findProviderError(err) {
|
|
59
|
+
let cursor = err;
|
|
60
|
+
for (let i = 0; i < MAX_CAUSE_HOPS && cursor; i++) {
|
|
61
|
+
if (isProviderError(cursor))
|
|
62
|
+
return cursor;
|
|
63
|
+
cursor = cursor.cause;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
36
67
|
export function isBudgetExceeded(err) {
|
|
37
68
|
if (hasStatus402(err))
|
|
38
69
|
return true;
|
|
70
|
+
const classified = findProviderError(err);
|
|
71
|
+
if (classified) {
|
|
72
|
+
if (classified.type === "budget")
|
|
73
|
+
return true;
|
|
74
|
+
// `auth` and `tenant-blocked` exist only because a status produced them, and
|
|
75
|
+
// a `provider` arm that carried a status was decided on it. None can be
|
|
76
|
+
// overturned by a phrase inside a response body the message merely quotes.
|
|
77
|
+
//
|
|
78
|
+
// A `provider` arm with NO status is the one exception: there
|
|
79
|
+
// `classifyProviderError` had nothing to key on and applied a fall-through
|
|
80
|
+
// default, so it made no determination to respect. That is exactly the shape
|
|
81
|
+
// a gateway reporting `budget_exceeded` as plain text arrives in, and the
|
|
82
|
+
// patterns below are the reason it is still caught.
|
|
83
|
+
if (classified.type !== "provider" || extractStatus(classified) !== undefined)
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
39
86
|
const msg = err instanceof Error ? err.message : String(err ?? "");
|
|
40
87
|
return BUDGET_EXCEEDED_PATTERNS.some((p) => p.test(msg));
|
|
41
88
|
}
|