@inflexa-ai/harness 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/conversation-agent.d.ts +9 -15
- package/dist/agents/conversation-agent.js +9 -15
- package/dist/agents/sandbox/index.js +0 -3
- package/dist/agents/sandbox/shared.d.ts +2 -2
- package/dist/agents/sandbox/shared.js +1 -1
- package/dist/config/resource-limits.d.ts +0 -7
- package/dist/config/resource-limits.js +0 -1
- package/dist/execution/dbos-run-launcher.js +1 -26
- package/dist/execution/run-authorizer.d.ts +1 -1
- package/dist/execution/run-authorizer.js +1 -1
- package/dist/execution/run-launcher.d.ts +2 -18
- package/dist/execution/run-launcher.js +2 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -6
- package/dist/loop/run-agent.js +1 -0
- package/dist/memory/reconstruct-cards.d.ts +1 -1
- package/dist/memory/reconstruct-cards.js +7 -5
- package/dist/memory/thread-history.js +39 -1
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +27 -10
- package/dist/prompts/planner.d.ts +3 -0
- package/dist/prompts/planner.js +1 -1
- package/dist/prompts/sandbox-standards.d.ts +4 -4
- package/dist/prompts/sandbox-standards.js +5 -5
- package/dist/runtime/assemble.d.ts +5 -10
- package/dist/runtime/assemble.js +0 -4
- package/dist/runtime/boot.d.ts +2 -2
- package/dist/runtime/boot.js +1 -1
- package/dist/runtime/dbos.d.ts +4 -5
- package/dist/runtime/dbos.js +6 -7
- package/dist/sandbox/mount-plan.d.ts +1 -1
- package/dist/sandbox/sandbox-error.d.ts +1 -1
- package/dist/sandbox/sandbox-error.js +1 -1
- package/dist/sandbox/types.d.ts +1 -1
- package/dist/schemas/validate-plan.d.ts +1 -1
- package/dist/state/index.d.ts +4 -4
- package/dist/state/index.js +2 -2
- package/dist/state/plans.js +1 -1
- package/dist/state/runs.d.ts +10 -0
- package/dist/state/runs.js +47 -0
- package/dist/tools/__fixtures__/tool-context.js +1 -0
- package/dist/tools/ad-hoc-router.d.ts +31 -0
- package/dist/tools/ad-hoc-router.js +132 -0
- package/dist/tools/analysis-invocation.d.ts +4 -0
- package/dist/tools/analysis-invocation.js +17 -0
- package/dist/tools/bio/search-geo-datasets.js +2 -1
- package/dist/tools/define-tool.d.ts +6 -2
- package/dist/tools/define-tool.js +1 -1
- package/dist/tools/execute-analysis.d.ts +43 -0
- package/dist/tools/execute-analysis.js +252 -0
- package/dist/tools/iterate-report.js +7 -6
- package/dist/tools/research/generate-plan.js +1 -1
- package/dist/tools/workspace/list-files.js +4 -3
- package/dist/workflows/execute-analysis.d.ts +5 -5
- package/dist/workflows/execute-analysis.js +3 -5
- package/package.json +1 -1
- package/dist/agents/sandbox/ephemeral-executor.d.ts +0 -9
- package/dist/agents/sandbox/ephemeral-executor.js +0 -20
- package/dist/execution/ephemeral-runner.d.ts +0 -98
- package/dist/execution/ephemeral-runner.js +0 -189
- package/dist/prompts/sandbox/ephemeral-executor.d.ts +0 -1
- package/dist/prompts/sandbox/ephemeral-executor.js +0 -66
- package/dist/tools/execute-plan.d.ts +0 -64
- package/dist/tools/execute-plan.js +0 -211
- package/dist/tools/run-ephemeral.d.ts +0 -33
- package/dist/tools/run-ephemeral.js +0 -65
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ephemeral Executor — read-only sandbox agent for quick computations.
|
|
3
|
-
* Cannot create files; step conventions don't apply.
|
|
4
|
-
*/
|
|
5
|
-
import { ephemeralExecutorPrompt } from "../../prompts/sandbox/ephemeral-executor.js";
|
|
6
|
-
import { BASE_SANDBOX_TOOLS, createSandboxAgent } from "./shared.js";
|
|
7
|
-
export const meta = {
|
|
8
|
-
id: "ephemeral-executor",
|
|
9
|
-
capabilities: ["data inspection", "quick statistics", "data transformation", "table preview"],
|
|
10
|
-
suitableFor: ["bulk-rna-seq", "single-cell", "proteomics", "metabolomics", "genomics", "transcriptomics", "general-omics"],
|
|
11
|
-
skills: ["shared/omics-general"],
|
|
12
|
-
tools: [...BASE_SANDBOX_TOOLS, "pubmed", "searchGene", "searchPathway", "lookupGoTerm", "searchInteractions"],
|
|
13
|
-
plannable: false,
|
|
14
|
-
};
|
|
15
|
-
export function createEphemeralExecutorAgent(deps) {
|
|
16
|
-
return createSandboxAgent(deps, meta, ephemeralExecutorPrompt, {
|
|
17
|
-
appendAnalysisStepStandards: false,
|
|
18
|
-
readOnly: true,
|
|
19
|
-
});
|
|
20
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ephemeral runner — read-only sandbox execution for ad-hoc data
|
|
3
|
-
* exploration, run as a turn-scoped DBOS workflow.
|
|
4
|
-
*
|
|
5
|
-
* The `run_ephemeral` conversation tool authorizes the run at the async
|
|
6
|
-
* edge, starts this workflow, and awaits its result inline within the chat
|
|
7
|
-
* turn. The workflow is "bare": it has no run-event stream, so `emit` is a
|
|
8
|
-
* no-op and live sandbox progress events are dropped. It is never recovered
|
|
9
|
-
* — chat disconnect cancels it (`DBOS.cancelWorkflow`), and a boot-time sweep
|
|
10
|
-
* cancels any `ephemeral:`-prefixed PENDING workflow a dead pod left behind.
|
|
11
|
-
*
|
|
12
|
-
* Lifecycle: create sandbox → build the ephemeral-executor with synthetic
|
|
13
|
-
* step coords (`runId = stepId = "ephemeral"`, the literal the host-managed
|
|
14
|
-
* billing rules key on) → drive `runAgent` over the executor with `durableStep` →
|
|
15
|
-
* final text + step count → teardown the sandbox in `finally` on every
|
|
16
|
-
* terminal path.
|
|
17
|
-
*
|
|
18
|
-
* Read-only is enforced, not aspirational: the executor agent is built with
|
|
19
|
-
* `readOnly: true` (no `write_file`/`edit_file` tools) and its sandbox is
|
|
20
|
-
* provisioned with no read-write mount — only the read-only analysis tree.
|
|
21
|
-
* Container-local `/tmp` stays writable (torn down with the sandbox); nothing
|
|
22
|
-
* persists. The prompt's "you physically cannot write files" is now true.
|
|
23
|
-
*/
|
|
24
|
-
import type { Pool } from "pg";
|
|
25
|
-
import type { RunSession } from "../auth/types.js";
|
|
26
|
-
import type { EnvironmentStorePaths } from "../config/environment-stores.js";
|
|
27
|
-
import type { ResourcePolicy } from "../config/resource-limits.js";
|
|
28
|
-
import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
|
|
29
|
-
import type { SandboxClient } from "../sandbox/client.js";
|
|
30
|
-
import type { BioToolKeys } from "../tools/bio/keys.js";
|
|
31
|
-
import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
|
|
32
|
-
import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
|
|
33
|
-
import type { Logger } from "../lib/logger.js";
|
|
34
|
-
/** Sub-agent identity — appended to `callPath`, set as `agentId`. */
|
|
35
|
-
export declare const EPHEMERAL_AGENT_ID = "ephemeral-executor";
|
|
36
|
-
/** Workflow-id prefix — the boot-time never-recover sweep matches on it. */
|
|
37
|
-
export declare const EPHEMERAL_WORKFLOW_PREFIX: "ephemeral:";
|
|
38
|
-
/** The body's construction-time deps — closed over at registration. */
|
|
39
|
-
export interface EphemeralDeps extends EnvironmentStorePaths {
|
|
40
|
-
/** Operational logging seam; omitted falls back to no-op. */
|
|
41
|
-
readonly logger?: Logger;
|
|
42
|
-
readonly provider: ChatProvider;
|
|
43
|
-
readonly pool: Pool;
|
|
44
|
-
readonly sandboxClient: SandboxClient;
|
|
45
|
-
readonly workspaceFs: WorkspaceFilesystem;
|
|
46
|
-
/** Embedding provider for the executor's in-sandbox `workspace_search`. */
|
|
47
|
-
readonly embedding: EmbeddingProvider;
|
|
48
|
-
/** Workspace-root resolution seam (see workspace/paths.ts). */
|
|
49
|
-
readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
|
|
50
|
-
/** Model id — provenance / metric label; the provider owns the wire model. */
|
|
51
|
-
readonly model: string;
|
|
52
|
-
/** API keys for the bio/chem tools the ephemeral executor may use. */
|
|
53
|
-
readonly bioKeys: BioToolKeys;
|
|
54
|
-
/** Host resource policy — its `ephemeral` spec overrides the default sandbox size. */
|
|
55
|
-
readonly resourcePolicy?: ResourcePolicy;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Workflow input. JSON-serialisable — the authorized `RunSession` carries the
|
|
59
|
-
* run credential; the body reads it and never re-authorizes.
|
|
60
|
-
*/
|
|
61
|
-
export interface EphemeralWorkflowInput {
|
|
62
|
-
readonly runSession: RunSession;
|
|
63
|
-
readonly prompt: string;
|
|
64
|
-
/** Optional cap on the inner agent's tool-call budget. */
|
|
65
|
-
readonly maxIterations?: number;
|
|
66
|
-
/** Optional deadline override for sandbox-server execs (unix ms). */
|
|
67
|
-
readonly deadlineMs?: number;
|
|
68
|
-
}
|
|
69
|
-
export interface EphemeralResult {
|
|
70
|
-
readonly text: string;
|
|
71
|
-
readonly durationMs: number;
|
|
72
|
-
readonly stepsUsed: number;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Register the ephemeral workflow with DBOS. Returns the registered callable
|
|
76
|
-
* so the `run_ephemeral` tool can dispatch via `DBOS.startWorkflow`.
|
|
77
|
-
*/
|
|
78
|
-
export declare function registerEphemeralWorkflow(deps: EphemeralDeps): (input: EphemeralWorkflowInput) => Promise<EphemeralResult>;
|
|
79
|
-
/**
|
|
80
|
-
* The executor's seed: its workspace frame, then the caller's ask.
|
|
81
|
-
*
|
|
82
|
-
* An ephemeral run is not scheduled by `executeAnalysis`, so no step briefing is
|
|
83
|
-
* composed for it — and the sandbox system prompt names no paths (it is static
|
|
84
|
-
* per agent type, so the provider's prompt cache can reuse it). The analysis
|
|
85
|
-
* root would otherwise reach the agent nowhere: `run_ephemeral`'s `prompt` is
|
|
86
|
-
* free text authored by the conversation agent.
|
|
87
|
-
*
|
|
88
|
-
* The frame differs from a step's, so this does not reuse `renderWorkspace`:
|
|
89
|
-
* there is no writable directory to name. The read-only tree IS the cwd.
|
|
90
|
-
*/
|
|
91
|
-
export declare function ephemeralSeed(analysisId: string, prompt: string): string;
|
|
92
|
-
/**
|
|
93
|
-
* Run the ephemeral-executor agent against a fresh read-only sandbox.
|
|
94
|
-
* Throws on sandbox start failure; otherwise returns the agent's final
|
|
95
|
-
* text plus timing. Sandbox teardown fires on every terminal path —
|
|
96
|
-
* success, error, cancel.
|
|
97
|
-
*/
|
|
98
|
-
export declare function runEphemeralBody(input: EphemeralWorkflowInput, deps: EphemeralDeps): Promise<EphemeralResult>;
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ephemeral runner — read-only sandbox execution for ad-hoc data
|
|
3
|
-
* exploration, run as a turn-scoped DBOS workflow.
|
|
4
|
-
*
|
|
5
|
-
* The `run_ephemeral` conversation tool authorizes the run at the async
|
|
6
|
-
* edge, starts this workflow, and awaits its result inline within the chat
|
|
7
|
-
* turn. The workflow is "bare": it has no run-event stream, so `emit` is a
|
|
8
|
-
* no-op and live sandbox progress events are dropped. It is never recovered
|
|
9
|
-
* — chat disconnect cancels it (`DBOS.cancelWorkflow`), and a boot-time sweep
|
|
10
|
-
* cancels any `ephemeral:`-prefixed PENDING workflow a dead pod left behind.
|
|
11
|
-
*
|
|
12
|
-
* Lifecycle: create sandbox → build the ephemeral-executor with synthetic
|
|
13
|
-
* step coords (`runId = stepId = "ephemeral"`, the literal the host-managed
|
|
14
|
-
* billing rules key on) → drive `runAgent` over the executor with `durableStep` →
|
|
15
|
-
* final text + step count → teardown the sandbox in `finally` on every
|
|
16
|
-
* terminal path.
|
|
17
|
-
*
|
|
18
|
-
* Read-only is enforced, not aspirational: the executor agent is built with
|
|
19
|
-
* `readOnly: true` (no `write_file`/`edit_file` tools) and its sandbox is
|
|
20
|
-
* provisioned with no read-write mount — only the read-only analysis tree.
|
|
21
|
-
* Container-local `/tmp` stays writable (torn down with the sandbox); nothing
|
|
22
|
-
* persists. The prompt's "you physically cannot write files" is now true.
|
|
23
|
-
*/
|
|
24
|
-
import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
25
|
-
import { forSubAgent } from "../auth/types.js";
|
|
26
|
-
import { finalText, runAgent } from "../loop/run-agent.js";
|
|
27
|
-
import { durableStep } from "../loop/run-step.js";
|
|
28
|
-
import { generateExecutionId } from "../sandbox/execution-id.js";
|
|
29
|
-
import { mintSandboxIdentity } from "../sandbox/identity.js";
|
|
30
|
-
import { createEphemeralExecutorAgent } from "../agents/sandbox/ephemeral-executor.js";
|
|
31
|
-
import { createNoopLogger } from "../lib/console-logger.js";
|
|
32
|
-
/** Sub-agent identity — appended to `callPath`, set as `agentId`. */
|
|
33
|
-
export const EPHEMERAL_AGENT_ID = "ephemeral-executor";
|
|
34
|
-
/**
|
|
35
|
-
* Synthetic run/step literal stamped into the agent's step coords.
|
|
36
|
-
* Host-managed billing rules key on this literal to recognize chat-turn
|
|
37
|
-
* ephemeral runs (no `cortex_runs` row, no DBOS stream).
|
|
38
|
-
*/
|
|
39
|
-
const EPHEMERAL_RUN_LITERAL = "ephemeral";
|
|
40
|
-
/** Workflow-id prefix — the boot-time never-recover sweep matches on it. */
|
|
41
|
-
export const EPHEMERAL_WORKFLOW_PREFIX = "ephemeral:";
|
|
42
|
-
/** Default budget — chat-turn-scoped, no recovery, no checkpointing. */
|
|
43
|
-
const DEFAULT_DEADLINE_MS = 120_000;
|
|
44
|
-
/** Ephemeral runs have no planner estimate, so the sandbox box is chosen here
|
|
45
|
-
* explicitly — the policy's `ephemeral` spec when supplied, otherwise this
|
|
46
|
-
* mid-range default; either way clamped to cluster limits at create time. */
|
|
47
|
-
const EPHEMERAL_SANDBOX_RESOURCES = { cpu: 4, memoryGb: 8 };
|
|
48
|
-
/**
|
|
49
|
-
* Register the ephemeral workflow with DBOS. Returns the registered callable
|
|
50
|
-
* so the `run_ephemeral` tool can dispatch via `DBOS.startWorkflow`.
|
|
51
|
-
*/
|
|
52
|
-
export function registerEphemeralWorkflow(deps) {
|
|
53
|
-
return DBOS.registerWorkflow((input) => runEphemeralBody(input, deps), { name: "ephemeral" });
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* The executor's seed: its workspace frame, then the caller's ask.
|
|
57
|
-
*
|
|
58
|
-
* An ephemeral run is not scheduled by `executeAnalysis`, so no step briefing is
|
|
59
|
-
* composed for it — and the sandbox system prompt names no paths (it is static
|
|
60
|
-
* per agent type, so the provider's prompt cache can reuse it). The analysis
|
|
61
|
-
* root would otherwise reach the agent nowhere: `run_ephemeral`'s `prompt` is
|
|
62
|
-
* free text authored by the conversation agent.
|
|
63
|
-
*
|
|
64
|
-
* The frame differs from a step's, so this does not reuse `renderWorkspace`:
|
|
65
|
-
* there is no writable directory to name. The read-only tree IS the cwd.
|
|
66
|
-
*/
|
|
67
|
-
export function ephemeralSeed(analysisId, prompt) {
|
|
68
|
-
const root = `/${analysisId}`;
|
|
69
|
-
return [
|
|
70
|
-
"## Workspace",
|
|
71
|
-
`- Analysis root (read-only): \`${root}\` — inputs at \`${root}/data/inputs/\`, prior runs at \`${root}/runs/\`.`,
|
|
72
|
-
`- The analysis root is also your cwd, and what relative paths resolve against. The sandbox is read-only: there is nowhere to write, so return every result inline.`,
|
|
73
|
-
"",
|
|
74
|
-
prompt,
|
|
75
|
-
].join("\n");
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Run the ephemeral-executor agent against a fresh read-only sandbox.
|
|
79
|
-
* Throws on sandbox start failure; otherwise returns the agent's final
|
|
80
|
-
* text plus timing. Sandbox teardown fires on every terminal path —
|
|
81
|
-
* success, error, cancel.
|
|
82
|
-
*/
|
|
83
|
-
export async function runEphemeralBody(input, deps) {
|
|
84
|
-
const { runSession } = input;
|
|
85
|
-
if (runSession.scope.kind !== "analysis") {
|
|
86
|
-
throw new Error(`runEphemeralBody requires an analysis-scoped session — got ${runSession.scope.kind}`);
|
|
87
|
-
}
|
|
88
|
-
const analysisId = runSession.scope.analysisId;
|
|
89
|
-
// Bound after the scope guard: `analysisId` comes off the session's scope,
|
|
90
|
-
// not the workflow input.
|
|
91
|
-
const logger = (deps.logger ?? createNoopLogger()).named("ephemeral").with({ analysisId });
|
|
92
|
-
const startedAt = performance.now();
|
|
93
|
-
const executionId = generateExecutionId(EPHEMERAL_AGENT_ID);
|
|
94
|
-
const workflowId = DBOS.workflowID ?? `${EPHEMERAL_WORKFLOW_PREFIX}${executionId}`;
|
|
95
|
-
const childSession = forSubAgent(runSession, EPHEMERAL_AGENT_ID);
|
|
96
|
-
// Resolver throw = loud workflow failure, per the workspace-root-resolution contract.
|
|
97
|
-
const workspaceRoot = deps.resolveWorkspaceRoot(analysisId);
|
|
98
|
-
logger.info("starting sandbox", { executionId });
|
|
99
|
-
const sandbox = await deps.sandboxClient.createSandbox({
|
|
100
|
-
runId: EPHEMERAL_RUN_LITERAL,
|
|
101
|
-
stepId: EPHEMERAL_RUN_LITERAL,
|
|
102
|
-
analysisId,
|
|
103
|
-
execId: null,
|
|
104
|
-
childWorkflowId: workflowId,
|
|
105
|
-
resources: deps.resourcePolicy?.ephemeral ?? EPHEMERAL_SANDBOX_RESOURCES,
|
|
106
|
-
// No read-write step mount — only the read-only analysis tree.
|
|
107
|
-
readOnly: true,
|
|
108
|
-
}, mintSandboxIdentity(EPHEMERAL_RUN_LITERAL));
|
|
109
|
-
try {
|
|
110
|
-
// The fallback reads the CHECKPOINTED clock, not `Date.now()`: `awaitExec`
|
|
111
|
-
// gates on this absolute deadline, and its recovery-pull is a durable step,
|
|
112
|
-
// so a wall-clock deadline that grew on replay would shift which loop
|
|
113
|
-
// iteration crosses the deadline and desynchronise the recorded function-ID
|
|
114
|
-
// sequence (see the harness-durable-runtime spec; sandbox-step.ts does the
|
|
115
|
-
// same). `input.deadlineMs`, when present, is already replay-stable.
|
|
116
|
-
const deadlineAbs = input.deadlineMs ?? (await DBOS.now()) + DEFAULT_DEADLINE_MS;
|
|
117
|
-
const nextFunctionId = makeNextFunctionId();
|
|
118
|
-
const sandboxAgentDeps = {
|
|
119
|
-
provider: deps.provider,
|
|
120
|
-
pool: deps.pool,
|
|
121
|
-
sandboxClient: deps.sandboxClient,
|
|
122
|
-
workspaceFs: deps.workspaceFs,
|
|
123
|
-
embedding: deps.embedding,
|
|
124
|
-
model: deps.model,
|
|
125
|
-
bioKeys: deps.bioKeys,
|
|
126
|
-
...(deps.packagesFile ? { packagesFile: deps.packagesFile } : {}),
|
|
127
|
-
...(deps.refStorePath ? { refStorePath: deps.refStorePath } : {}),
|
|
128
|
-
step: {
|
|
129
|
-
sandbox,
|
|
130
|
-
workspaceRoot,
|
|
131
|
-
analysisId,
|
|
132
|
-
runId: EPHEMERAL_RUN_LITERAL,
|
|
133
|
-
stepId: EPHEMERAL_RUN_LITERAL,
|
|
134
|
-
workflowId,
|
|
135
|
-
// Enforced read-only: the executor agent gets no write_file/edit_file
|
|
136
|
-
// tools and the sandbox has no read-write mount. This prefix is not a
|
|
137
|
-
// writable location — it only resolves `execute_command`'s default cwd
|
|
138
|
-
// to the read-only analysis-tree root (`/{analysisId}`), where reads land.
|
|
139
|
-
allowedWritePrefix: workspaceRoot,
|
|
140
|
-
nextFunctionId,
|
|
141
|
-
deadlineMs: () => deadlineAbs,
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
const agentDef = createEphemeralExecutorAgent(sandboxAgentDeps);
|
|
145
|
-
const agent = input.maxIterations !== undefined ? { ...agentDef, maxIterations: input.maxIterations } : agentDef;
|
|
146
|
-
const signal = new AbortController().signal;
|
|
147
|
-
const { messages: finalMessages } = await runAgent(agent, [{ role: "user", content: ephemeralSeed(analysisId, input.prompt) }], childSession, {
|
|
148
|
-
provider: deps.provider,
|
|
149
|
-
signal,
|
|
150
|
-
emit: () => { },
|
|
151
|
-
runStep: durableStep,
|
|
152
|
-
isFatalLoopError: (err) => err instanceof DBOSErrors.DBOSWorkflowCancelledError,
|
|
153
|
-
});
|
|
154
|
-
const text = finalText(finalMessages);
|
|
155
|
-
const stepsUsed = countAssistantTurns(finalMessages);
|
|
156
|
-
const durationMs = Math.round(performance.now() - startedAt);
|
|
157
|
-
logger.info("completed", { executionId, stepsUsed, durationMs });
|
|
158
|
-
return { text, stepsUsed, durationMs };
|
|
159
|
-
}
|
|
160
|
-
catch (err) {
|
|
161
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
162
|
-
logger.error("failed", { executionId, err: message });
|
|
163
|
-
throw err;
|
|
164
|
-
}
|
|
165
|
-
finally {
|
|
166
|
-
try {
|
|
167
|
-
await deps.sandboxClient.teardown(sandbox);
|
|
168
|
-
}
|
|
169
|
-
catch (teardownErr) {
|
|
170
|
-
// Teardown is idempotent — "already gone" is success. A failure here
|
|
171
|
-
// is best-effort logged so the chat turn doesn't fail on cleanup.
|
|
172
|
-
logger.warn("teardown failed (non-fatal)", { executionId, ...logger.errorFields(teardownErr) });
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
/** Per-call function-id minter — replay-deterministic. */
|
|
177
|
-
function makeNextFunctionId() {
|
|
178
|
-
let n = 0;
|
|
179
|
-
return () => `fn-${(n++).toString(36)}`;
|
|
180
|
-
}
|
|
181
|
-
/** The agent's final assistant turn is the `stepsUsed` proxy — every loop
|
|
182
|
-
* iteration produces one assistant message. */
|
|
183
|
-
function countAssistantTurns(messages) {
|
|
184
|
-
let n = 0;
|
|
185
|
-
for (const m of messages)
|
|
186
|
-
if (m.role === "assistant")
|
|
187
|
-
n++;
|
|
188
|
-
return n;
|
|
189
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const ephemeralExecutorPrompt = "# Ephemeral Executor\n\nYou are a read-only compute agent. You execute scripts on the user's data\nand return the results. You **cannot create or save files** \u2014 your sandbox\nis fully read-only.\n\n## What You Do\n\n1. Read data files to understand their structure\n2. Write and execute R or Python scripts via `execute_command`\n3. Return the script output (tables, statistics, plot data) in your response\n\n## Constraints\n\n- **Read-only**: You physically cannot write files. Do not attempt to save\n output to disk. All results must be returned in your text response.\n- **Inline output**: Print results to stdout. For plots, use base64 encoding\n and print the encoded string so the caller can display it.\n- **Structured responses**: When returning tabular data, format as markdown\n tables or JSON arrays. When returning statistics, use clear labeled output.\n\n## Script Execution Pattern\n\nFor Python:\n```\nexecute_command python3 -c \"\nimport pandas as pd\ndf = pd.read_csv('/{resourceId}/data/inputs/{fileId}/data.csv')\nprint(df.describe().to_markdown())\n\"\n```\n\nFor R:\n```\nexecute_command Rscript -e \"\ndf <- read.csv('/{resourceId}/data/inputs/{fileId}/data.csv')\nsummary(df)\n\"\n```\n\nFor plots (base64):\n```\nexecute_command python3 -c \"\nimport matplotlib\nmatplotlib.use('Agg')\nimport matplotlib.pyplot as plt\nimport pandas as pd\nimport base64, io\n\ndf = pd.read_csv('...')\nfig, ax = plt.subplots()\ndf.plot(ax=ax)\nbuf = io.BytesIO()\nfig.savefig(buf, format='png')\nprint(base64.b64encode(buf.getvalue()).decode())\n\"\n```\n\n## Do NOT\n\n- Attempt to write files \u2014 it will fail silently or error\n- Install packages at runtime\n- Return raw binary data without base64 encoding\n- Guess data structure without inspecting the file first\n- Run long-running computations (you have a short timeout)\n";
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
export const ephemeralExecutorPrompt = `# Ephemeral Executor
|
|
2
|
-
|
|
3
|
-
You are a read-only compute agent. You execute scripts on the user's data
|
|
4
|
-
and return the results. You **cannot create or save files** — your sandbox
|
|
5
|
-
is fully read-only.
|
|
6
|
-
|
|
7
|
-
## What You Do
|
|
8
|
-
|
|
9
|
-
1. Read data files to understand their structure
|
|
10
|
-
2. Write and execute R or Python scripts via \`execute_command\`
|
|
11
|
-
3. Return the script output (tables, statistics, plot data) in your response
|
|
12
|
-
|
|
13
|
-
## Constraints
|
|
14
|
-
|
|
15
|
-
- **Read-only**: You physically cannot write files. Do not attempt to save
|
|
16
|
-
output to disk. All results must be returned in your text response.
|
|
17
|
-
- **Inline output**: Print results to stdout. For plots, use base64 encoding
|
|
18
|
-
and print the encoded string so the caller can display it.
|
|
19
|
-
- **Structured responses**: When returning tabular data, format as markdown
|
|
20
|
-
tables or JSON arrays. When returning statistics, use clear labeled output.
|
|
21
|
-
|
|
22
|
-
## Script Execution Pattern
|
|
23
|
-
|
|
24
|
-
For Python:
|
|
25
|
-
\`\`\`
|
|
26
|
-
execute_command python3 -c "
|
|
27
|
-
import pandas as pd
|
|
28
|
-
df = pd.read_csv('/{resourceId}/data/inputs/{fileId}/data.csv')
|
|
29
|
-
print(df.describe().to_markdown())
|
|
30
|
-
"
|
|
31
|
-
\`\`\`
|
|
32
|
-
|
|
33
|
-
For R:
|
|
34
|
-
\`\`\`
|
|
35
|
-
execute_command Rscript -e "
|
|
36
|
-
df <- read.csv('/{resourceId}/data/inputs/{fileId}/data.csv')
|
|
37
|
-
summary(df)
|
|
38
|
-
"
|
|
39
|
-
\`\`\`
|
|
40
|
-
|
|
41
|
-
For plots (base64):
|
|
42
|
-
\`\`\`
|
|
43
|
-
execute_command python3 -c "
|
|
44
|
-
import matplotlib
|
|
45
|
-
matplotlib.use('Agg')
|
|
46
|
-
import matplotlib.pyplot as plt
|
|
47
|
-
import pandas as pd
|
|
48
|
-
import base64, io
|
|
49
|
-
|
|
50
|
-
df = pd.read_csv('...')
|
|
51
|
-
fig, ax = plt.subplots()
|
|
52
|
-
df.plot(ax=ax)
|
|
53
|
-
buf = io.BytesIO()
|
|
54
|
-
fig.savefig(buf, format='png')
|
|
55
|
-
print(base64.b64encode(buf.getvalue()).decode())
|
|
56
|
-
"
|
|
57
|
-
\`\`\`
|
|
58
|
-
|
|
59
|
-
## Do NOT
|
|
60
|
-
|
|
61
|
-
- Attempt to write files — it will fail silently or error
|
|
62
|
-
- Install packages at runtime
|
|
63
|
-
- Return raw binary data without base64 encoding
|
|
64
|
-
- Guess data structure without inspecting the file first
|
|
65
|
-
- Run long-running computations (you have a short timeout)
|
|
66
|
-
`;
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* executePlan — launch the DBOS `executeAnalysis` parent workflow for an
|
|
3
|
-
* approved plan.
|
|
4
|
-
*
|
|
5
|
-
* Results are pull-only: the tool does NOT register a `.then(saveRunResult)`
|
|
6
|
-
* or any completion callback that writes to the conversation thread. The
|
|
7
|
-
* conversation agent retrieves run results via `inspectRun` on a later turn.
|
|
8
|
-
*
|
|
9
|
-
* Authorization contract: the tool runs in the chat HTTP route, so its session
|
|
10
|
-
* carries the live auth. It authorizes through the injected `RunAuthorizer`
|
|
11
|
-
* seam and never reads a credential. The flow is dedup → reserve → authorize
|
|
12
|
-
* → startWorkflow:
|
|
13
|
-
*
|
|
14
|
-
* 1. Dedup pre-check via `queryActiveRun`. On hit, return the existing
|
|
15
|
-
* `runId` with no authorization and no `startWorkflow`.
|
|
16
|
-
* 2. Reserve the slot: `insertRun` the `cortex_runs` row (`runId =
|
|
17
|
-
* randomUUID()`, the bare UUID that IS the DBOS workflowID) BEFORE
|
|
18
|
-
* authorizing. The partial-unique index is the race backstop; the loser
|
|
19
|
-
* returns the winner's `runId` without authorizing — nothing to revoke.
|
|
20
|
-
* 3. `runAuthorizer.authorize({ ..., frame: { runId } })` — authorizes the
|
|
21
|
-
* run; the authorizer's persisted handle now lands because the row
|
|
22
|
-
* exists. On failure, mark the reserved row `failed` (releasing the dedup
|
|
23
|
-
* slot for a retry) and rethrow.
|
|
24
|
-
* 4. `runLauncher.launch(executeAnalysis, { workflowId: runId }, ...)`,
|
|
25
|
-
* passing the `RunSession` in the workflow input. On failure, revoke + mark
|
|
26
|
-
* failed.
|
|
27
|
-
*/
|
|
28
|
-
import type { Pool } from "pg";
|
|
29
|
-
import type { ResourcePolicy } from "../config/resource-limits.js";
|
|
30
|
-
import type { RunAuthorizer } from "../execution/run-authorizer.js";
|
|
31
|
-
import type { RunLauncher } from "../execution/run-launcher.js";
|
|
32
|
-
import type { ExecuteAnalysisInput, ExecuteAnalysisResult } from "../workflows/execute-analysis.js";
|
|
33
|
-
type ExecuteAnalysisWorkflow = (input: ExecuteAnalysisInput) => Promise<ExecuteAnalysisResult>;
|
|
34
|
-
export interface ExecutePlanToolDeps {
|
|
35
|
-
readonly pool: Pool;
|
|
36
|
-
/** Registered `executeAnalysis` workflow callable — produced by `registerExecuteAnalysis` (wired by `assembleCoreRuntime`). */
|
|
37
|
-
readonly executeAnalysisWorkflow: ExecuteAnalysisWorkflow;
|
|
38
|
-
/** Authorizes the run at the async edge and revokes on the abort paths. */
|
|
39
|
-
readonly runAuthorizer: RunAuthorizer;
|
|
40
|
-
/** Starts the durable run — the durability engine stays behind this seam. */
|
|
41
|
-
readonly runLauncher: RunLauncher;
|
|
42
|
-
/**
|
|
43
|
-
* Host resource policy. The machine budget is snapshotted into the workflow
|
|
44
|
-
* input here at the async edge — the workflow body never reads live config,
|
|
45
|
-
* so a mid-run config edit cannot change a running run's admission decisions.
|
|
46
|
-
*/
|
|
47
|
-
readonly resourcePolicy?: ResourcePolicy;
|
|
48
|
-
}
|
|
49
|
-
export declare class PlanNotFoundError extends Error {
|
|
50
|
-
constructor(planId: string);
|
|
51
|
-
}
|
|
52
|
-
export declare class PlanMalformedError extends Error {
|
|
53
|
-
constructor(planId: string);
|
|
54
|
-
}
|
|
55
|
-
export declare class PlanValidationError extends Error {
|
|
56
|
-
readonly errors: string[];
|
|
57
|
-
constructor(planId: string, errors: string[]);
|
|
58
|
-
}
|
|
59
|
-
export declare function createExecutePlanTool(deps: ExecutePlanToolDeps): import("./define-tool.js").Tool<{
|
|
60
|
-
planId: string;
|
|
61
|
-
}, {
|
|
62
|
-
runId: string;
|
|
63
|
-
}>;
|
|
64
|
-
export {};
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* executePlan — launch the DBOS `executeAnalysis` parent workflow for an
|
|
3
|
-
* approved plan.
|
|
4
|
-
*
|
|
5
|
-
* Results are pull-only: the tool does NOT register a `.then(saveRunResult)`
|
|
6
|
-
* or any completion callback that writes to the conversation thread. The
|
|
7
|
-
* conversation agent retrieves run results via `inspectRun` on a later turn.
|
|
8
|
-
*
|
|
9
|
-
* Authorization contract: the tool runs in the chat HTTP route, so its session
|
|
10
|
-
* carries the live auth. It authorizes through the injected `RunAuthorizer`
|
|
11
|
-
* seam and never reads a credential. The flow is dedup → reserve → authorize
|
|
12
|
-
* → startWorkflow:
|
|
13
|
-
*
|
|
14
|
-
* 1. Dedup pre-check via `queryActiveRun`. On hit, return the existing
|
|
15
|
-
* `runId` with no authorization and no `startWorkflow`.
|
|
16
|
-
* 2. Reserve the slot: `insertRun` the `cortex_runs` row (`runId =
|
|
17
|
-
* randomUUID()`, the bare UUID that IS the DBOS workflowID) BEFORE
|
|
18
|
-
* authorizing. The partial-unique index is the race backstop; the loser
|
|
19
|
-
* returns the winner's `runId` without authorizing — nothing to revoke.
|
|
20
|
-
* 3. `runAuthorizer.authorize({ ..., frame: { runId } })` — authorizes the
|
|
21
|
-
* run; the authorizer's persisted handle now lands because the row
|
|
22
|
-
* exists. On failure, mark the reserved row `failed` (releasing the dedup
|
|
23
|
-
* slot for a retry) and rethrow.
|
|
24
|
-
* 4. `runLauncher.launch(executeAnalysis, { workflowId: runId }, ...)`,
|
|
25
|
-
* passing the `RunSession` in the workflow input. On failure, revoke + mark
|
|
26
|
-
* failed.
|
|
27
|
-
*/
|
|
28
|
-
import { randomUUID } from "node:crypto";
|
|
29
|
-
import { ok } from "neverthrow";
|
|
30
|
-
import { z } from "zod";
|
|
31
|
-
import { buildRunCardData } from "../memory/card-builders.js";
|
|
32
|
-
import { unwrapOrThrow } from "../lib/result.js";
|
|
33
|
-
import { validatePlan } from "../schemas/validate-plan.js";
|
|
34
|
-
import { AnalysisPlanSchema } from "../schemas/workflow-state.js";
|
|
35
|
-
import { RunDedupCollisionError, insertRun, loadPlan, queryActiveRun, updateRunStatus } from "../state/index.js";
|
|
36
|
-
import { defineTool } from "./define-tool.js";
|
|
37
|
-
const planIdSchema = z.string().regex(/^pln-[a-f0-9]{8}$/, "planId must be a pln-<8hex> value");
|
|
38
|
-
const inputSchema = z.object({
|
|
39
|
-
planId: planIdSchema,
|
|
40
|
-
});
|
|
41
|
-
export class PlanNotFoundError extends Error {
|
|
42
|
-
constructor(planId) {
|
|
43
|
-
super(`Plan ${planId} not found for this analysis`);
|
|
44
|
-
this.name = "PlanNotFoundError";
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
export class PlanMalformedError extends Error {
|
|
48
|
-
constructor(planId) {
|
|
49
|
-
super(`Plan ${planId} is malformed`);
|
|
50
|
-
this.name = "PlanMalformedError";
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
export class PlanValidationError extends Error {
|
|
54
|
-
errors;
|
|
55
|
-
constructor(planId, errors) {
|
|
56
|
-
super(`Plan ${planId} failed validation: ${errors.length} error(s)`);
|
|
57
|
-
this.errors = errors;
|
|
58
|
-
this.name = "PlanValidationError";
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
function isDedupCollision(err) {
|
|
62
|
-
return err instanceof RunDedupCollisionError || (err instanceof Error && err.name === "RunDedupCollisionError");
|
|
63
|
-
}
|
|
64
|
-
export function createExecutePlanTool(deps) {
|
|
65
|
-
const { pool, executeAnalysisWorkflow, runAuthorizer, runLauncher, resourcePolicy } = deps;
|
|
66
|
-
return defineTool({
|
|
67
|
-
id: "execute_plan",
|
|
68
|
-
description: "Execute an approved analysis plan by its planId. " +
|
|
69
|
-
"Pass the planId returned from generatePlan — the tool resolves the plan " +
|
|
70
|
-
"server-side, validates the DAG, and starts the executeAnalysis workflow " +
|
|
71
|
-
"asynchronously. Returns the runId on success. " +
|
|
72
|
-
"The user sees a run card in chat (also tracked in the Runs panel); " +
|
|
73
|
-
"run results become available for inspection on a later turn. " +
|
|
74
|
-
"Do not instruct the user to invoke any tool.",
|
|
75
|
-
inputSchema,
|
|
76
|
-
execute: async (input, ctx) => {
|
|
77
|
-
const { planId } = input;
|
|
78
|
-
// Mirror show_plan: emit a live display card so chat renders a run card
|
|
79
|
-
// rather than a generic "used 1 tool" chip. Null (plan/run unresolvable)
|
|
80
|
-
// simply skips the card — the run still starts.
|
|
81
|
-
const emitRunCard = async (analysisId, runId) => {
|
|
82
|
-
const card = await buildRunCardData(pool, {
|
|
83
|
-
planId,
|
|
84
|
-
analysisId,
|
|
85
|
-
runId,
|
|
86
|
-
}).unwrapOr(null);
|
|
87
|
-
if (card) {
|
|
88
|
-
await ctx.emit({
|
|
89
|
-
type: "data-run-card",
|
|
90
|
-
source: ctx.session.provenance,
|
|
91
|
-
data: card,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
const { session } = ctx;
|
|
96
|
-
if (session.scope.kind !== "analysis") {
|
|
97
|
-
throw new Error("executePlan can only be invoked on an analysis-scoped session");
|
|
98
|
-
}
|
|
99
|
-
if (!session.auth) {
|
|
100
|
-
throw new Error("executePlan: session is missing its auth capability");
|
|
101
|
-
}
|
|
102
|
-
const analysisId = session.scope.analysisId;
|
|
103
|
-
const threadId = session.scope.threadId ?? null;
|
|
104
|
-
const raw = unwrapOrThrow(await loadPlan(pool, planId, { analysisId }));
|
|
105
|
-
if (!raw)
|
|
106
|
-
throw new PlanNotFoundError(planId);
|
|
107
|
-
const parsed = AnalysisPlanSchema.safeParse(raw);
|
|
108
|
-
if (!parsed.success)
|
|
109
|
-
throw new PlanMalformedError(planId);
|
|
110
|
-
const plan = parsed.data;
|
|
111
|
-
const result = validatePlan(plan);
|
|
112
|
-
if (!result.valid) {
|
|
113
|
-
throw new PlanValidationError(planId, result.errors);
|
|
114
|
-
}
|
|
115
|
-
// (1) Dedup pre-check — common case is the user re-clicked Execute on
|
|
116
|
-
// an in-flight plan. A hit avoids both the authorization and the DBOS
|
|
117
|
-
// startWorkflow.
|
|
118
|
-
const existingPre = unwrapOrThrow(await queryActiveRun(pool, analysisId, planId));
|
|
119
|
-
if (existingPre) {
|
|
120
|
-
await emitRunCard(analysisId, existingPre.runId);
|
|
121
|
-
return ok({ runId: existingPre.runId });
|
|
122
|
-
}
|
|
123
|
-
// (2) Reserve the dedup slot by inserting the run row BEFORE authorizing.
|
|
124
|
-
// The partial-unique index is the race backstop for two callers that both
|
|
125
|
-
// passed the pre-check; the loser returns the winner's runId without
|
|
126
|
-
// having authorized — there is nothing to revoke. The authorization handle is left
|
|
127
|
-
// null here and populated by the authorizer, whose UPDATE lands
|
|
128
|
-
// because the row now exists.
|
|
129
|
-
const runId = randomUUID();
|
|
130
|
-
try {
|
|
131
|
-
// On the dedup collision `insertRun` throws `RunDedupCollisionError`
|
|
132
|
-
// (a control-flow signal that rides out of the ResultAsync as a
|
|
133
|
-
// rejection) — `unwrapOrThrow` never sees it; the catch below does.
|
|
134
|
-
unwrapOrThrow(await insertRun(pool, {
|
|
135
|
-
runId,
|
|
136
|
-
analysisId,
|
|
137
|
-
threadId,
|
|
138
|
-
workflowName: "executeAnalysis",
|
|
139
|
-
planId,
|
|
140
|
-
}));
|
|
141
|
-
}
|
|
142
|
-
catch (err) {
|
|
143
|
-
if (isDedupCollision(err)) {
|
|
144
|
-
const existing = unwrapOrThrow(await queryActiveRun(pool, analysisId, planId));
|
|
145
|
-
if (existing) {
|
|
146
|
-
await emitRunCard(analysisId, existing.runId);
|
|
147
|
-
return ok({ runId: existing.runId });
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
throw err;
|
|
151
|
-
}
|
|
152
|
-
// (3) Authorize the run. The row exists, so the authorizer's persisted handle
|
|
153
|
-
// persists. On failure, mark the reserved row failed — releasing the
|
|
154
|
-
// partial-unique slot so a retry can re-run — and rethrow.
|
|
155
|
-
let authorization;
|
|
156
|
-
try {
|
|
157
|
-
authorization = await runAuthorizer.authorize({
|
|
158
|
-
auth: session.auth,
|
|
159
|
-
scope: session.scope,
|
|
160
|
-
provenance: session.provenance,
|
|
161
|
-
frame: { runId },
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
await updateRunStatus(pool, runId, "failed", "run authorization failed").match(() => { }, () => { });
|
|
166
|
-
throw err;
|
|
167
|
-
}
|
|
168
|
-
const { runSession } = authorization;
|
|
169
|
-
const planSummary = plan.title?.trim() || plan.analytical_narrative.trim().slice(0, 280);
|
|
170
|
-
const workflowInput = {
|
|
171
|
-
analysisId,
|
|
172
|
-
planId,
|
|
173
|
-
planSummary,
|
|
174
|
-
threadId,
|
|
175
|
-
steps: plan.steps.map((s) => ({
|
|
176
|
-
id: s.id,
|
|
177
|
-
depends_on: s.depends_on ?? [],
|
|
178
|
-
})),
|
|
179
|
-
// The step DATA, not a rendered prompt: each step's seed is composed
|
|
180
|
-
// by the scheduler at dispatch, when its dependencies' results exist.
|
|
181
|
-
// A prompt frozen here — before the first step has run — could never
|
|
182
|
-
// mention them.
|
|
183
|
-
planStepById: Object.fromEntries(plan.steps.map((s) => [s.id, s])),
|
|
184
|
-
agentByStepId: Object.fromEntries(plan.steps.map((s) => [s.id, s.agent ?? "unknown"])),
|
|
185
|
-
resourcesByStepId: Object.fromEntries(plan.steps.map((s) => {
|
|
186
|
-
if (!s.resources) {
|
|
187
|
-
throw new Error(`Step "${s.id}" has no resources — validatePlan should have rejected this plan`);
|
|
188
|
-
}
|
|
189
|
-
return [s.id, s.resources];
|
|
190
|
-
})),
|
|
191
|
-
timeoutByStepId: Object.fromEntries(plan.steps.filter((s) => s.timeout !== undefined).map((s) => [s.id, s.timeout])),
|
|
192
|
-
budget: resourcePolicy?.budget,
|
|
193
|
-
runSession,
|
|
194
|
-
ownsMandate: authorization.ownsMandate, // oss-core-managed-ok
|
|
195
|
-
};
|
|
196
|
-
// (4) Dispatch the workflow. `workflowId = runId` — both are the same
|
|
197
|
-
// bare UUID. On failure after insert + authorize, revoke the authorization and
|
|
198
|
-
// mark the row failed.
|
|
199
|
-
try {
|
|
200
|
-
await runLauncher.launch(executeAnalysisWorkflow, { workflowId: runId }, workflowInput);
|
|
201
|
-
}
|
|
202
|
-
catch (err) {
|
|
203
|
-
await runAuthorizer.revoke(authorization, "workflow-start-failed").catch(() => { });
|
|
204
|
-
await updateRunStatus(pool, runId, "failed", "workflow start failed").match(() => { }, () => { });
|
|
205
|
-
throw err;
|
|
206
|
-
}
|
|
207
|
-
await emitRunCard(analysisId, runId);
|
|
208
|
-
return ok({ runId });
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
}
|