feat: phase 3-5 — workflow engine, background subagents, multi-modal, cache shape, inline diff
Phase 3: Dynamic Workflow Engine - VM sandbox (node:vm) with agent/parallel/pipeline API, Claude Code compatible - Workflow file discovery (.boocode/workflows/*.js + ~/.boocode/workflows/*.js) - Workflow manager with session/chat creation and inference dispatch - Built-in catalog: deep-research, review-code, find-issues - Resumability cache: SHA-256 hash of agent spec, in-memory Map Phase 4: Background Subagents - background-task.ts service: spawn/poll/cancel lifecycle - spawn_subagent, subagent_status, subagent_result tools in ALL_TOOLS Phase 5: Multi-modal + Cache Shape - Multi-modal stub with type defs and hook point in payload.ts - CacheShapeBadge component in trace viewer (colored bar + %)
This commit is contained in:
376
apps/server/src/services/workflow/catalog.ts
Normal file
376
apps/server/src/services/workflow/catalog.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
// v2.8.0: Workflow catalog — built-in workflow definitions that ship with
|
||||
// BooCode. Each workflow is a metadata object with name, description, and a
|
||||
// factory function that returns the workflow script source code.
|
||||
//
|
||||
// Built-in workflows are merged into the discovery list alongside file-based
|
||||
// workflows from .boocode/workflows/. They take precedence over user-defined
|
||||
// workflows with the same name.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A built-in workflow definition shipped with BooCode.
|
||||
*/
|
||||
export interface BuiltinWorkflow {
|
||||
/** Unique workflow name (used to invoke via `WorkflowManager`). */
|
||||
name: string;
|
||||
/** Human-readable description of what this workflow does. */
|
||||
description: string;
|
||||
/** Optional ordered phases for UI progress display. */
|
||||
phases?: Array<{ title: string; detail?: string }>;
|
||||
/**
|
||||
* Generate the workflow script source code for this workflow.
|
||||
* The returned string must be valid JS that exports `meta` and a `default`
|
||||
* async function matching the `WorkflowScript` shape.
|
||||
*
|
||||
* @param args - Optional arguments provided when the workflow is started.
|
||||
*/
|
||||
generateScript: (args?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Script templates (shared helpers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stable JSON serialisation for generating deterministic cache keys from
|
||||
* structured arguments. Keys are sorted so the same data always produces
|
||||
* the same string regardless of property insertion order.
|
||||
*/
|
||||
function stableJson(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
if (typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableJson).join(',')}]`;
|
||||
}
|
||||
const keys = Object.keys(value as Record<string, unknown>).sort();
|
||||
const pairs = keys.map((k) => `${JSON.stringify(k)}:${stableJson((value as Record<string, unknown>)[k])}`);
|
||||
return `{${pairs.join(',')}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic SHA-256 fingerprint for a combined spec + args
|
||||
* payload. Used by the resumability cache to detect unchanged agent tasks.
|
||||
*
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function fingerprintAgentTask(
|
||||
prompt: string,
|
||||
spec: Record<string, unknown>,
|
||||
args: string,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(stableJson({ prompt, spec, args }))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in workflow definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateDeepResearchScript(_args?: Record<string, unknown>): string {
|
||||
return `
|
||||
export const meta = {
|
||||
name: 'deep-research',
|
||||
description: 'Multi-phase deep research: scope, search, fetch, verify, synthesise.',
|
||||
phases: [
|
||||
{ title: 'Scope', detail: 'Define the research question and search criteria' },
|
||||
{ title: 'Search', detail: 'Query web sources in parallel' },
|
||||
{ title: 'Fetch', detail: 'Retrieve full content from top sources' },
|
||||
{ title: 'Verify', detail: 'Cross-reference and validate findings' },
|
||||
{ title: 'Synthesise', detail: 'Produce a final structured report' },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function main(args) {
|
||||
const query = args?.query ?? 'No query provided';
|
||||
log('deep-research: starting with query: ' + query);
|
||||
|
||||
// Phase 1: Scope
|
||||
phase('Scope');
|
||||
const scope = await agent(
|
||||
'Analyse this research query and produce a search plan with 3-5 key sub-questions: ' + query,
|
||||
{ label: 'scope-analysis', phase: 'scope' },
|
||||
);
|
||||
log('Scope completed');
|
||||
|
||||
// Phase 2: Search
|
||||
phase('Search');
|
||||
const searchResults = await agent(
|
||||
'Based on the scope, search for authoritative sources. Return a list of 3-5 URLs with brief annotations.',
|
||||
{ label: 'web-search', phase: 'search' },
|
||||
);
|
||||
log('Search completed');
|
||||
|
||||
// Phase 3: Fetch
|
||||
phase('Fetch');
|
||||
const fetchedContent = await agent(
|
||||
'Extract and summarise the key information from these sources: ' + JSON.stringify(searchResults),
|
||||
{ label: 'content-fetch', phase: 'fetch' },
|
||||
);
|
||||
log('Fetch completed');
|
||||
|
||||
// Phase 4: Verify
|
||||
phase('Verify');
|
||||
const verified = await agent(
|
||||
'Cross-reference the fetched information. Note any contradictions, gaps, or weak sources: ' + JSON.stringify(fetchedContent),
|
||||
{ label: 'verification', phase: 'verify' },
|
||||
);
|
||||
log('Verify completed');
|
||||
|
||||
// Phase 5: Synthesise
|
||||
phase('Synthesise');
|
||||
const report = await agent(
|
||||
'Synthesise the verified information into a structured report with findings, sources, and confidence levels: ' + JSON.stringify(verified),
|
||||
{ label: 'synthesis', phase: 'synthesise' },
|
||||
);
|
||||
log('deep-research: completed');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
output: report,
|
||||
phases: { scope, searchResults, fetchedContent, verified, report },
|
||||
};
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function generateReviewCodeScript(_args?: Record<string, unknown>): string {
|
||||
return `
|
||||
export const meta = {
|
||||
name: 'review-code',
|
||||
description: 'Multi-perspective code review: correctness, security, performance, then synthesise.',
|
||||
phases: [
|
||||
{ title: 'Correctness', detail: 'Check logic, edge cases, and correctness' },
|
||||
{ title: 'Security', detail: 'Analyse for vulnerabilities and unsafe patterns' },
|
||||
{ title: 'Performance', detail: 'Identify performance bottlenecks and optimisation opportunities' },
|
||||
{ title: 'Synthesise', detail: 'Merge perspectives into a unified review report' },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function main(args) {
|
||||
const target = args?.target ?? args?.path ?? '';
|
||||
log('review-code: starting review of: ' + (target || '(no target specified)'));
|
||||
|
||||
const context = await agent(
|
||||
'Read the code at ' + (target || 'the provided context') + ' and produce a summary of its structure and purpose.',
|
||||
{ label: 'read-context', phase: 'context' },
|
||||
);
|
||||
|
||||
// Phase 1: Correctness
|
||||
phase('Correctness');
|
||||
const correctness = await agent(
|
||||
'Review this code for correctness. Check logical errors, edge cases, type safety, and concurrency issues:\\n' + JSON.stringify(context),
|
||||
{ label: 'correctness-review', phase: 'correctness' },
|
||||
);
|
||||
|
||||
// Phase 2: Security
|
||||
phase('Security');
|
||||
const security = await agent(
|
||||
'Review this code for security vulnerabilities. Check for injection, auth bypasses, unsafe deserialisation, secret exposure:\\n' + JSON.stringify(context),
|
||||
{ label: 'security-review', phase: 'security' },
|
||||
);
|
||||
|
||||
// Phase 3: Performance
|
||||
phase('Performance');
|
||||
const performance = await agent(
|
||||
'Review this code for performance issues. Check algorithmic complexity, unnecessary allocations, I/O patterns, caching opportunities:\\n' + JSON.stringify(context),
|
||||
{ label: 'performance-review', phase: 'performance' },
|
||||
);
|
||||
|
||||
// Phase 4: Synthesise
|
||||
phase('Synthesise');
|
||||
const report = await agent(
|
||||
'Merge these three review perspectives into one structured report with severity-ranked findings:\\n' +
|
||||
'--- Correctness ---\\n' + JSON.stringify(correctness) + '\\n' +
|
||||
'--- Security ---\\n' + JSON.stringify(security) + '\\n' +
|
||||
'--- Performance ---\\n' + JSON.stringify(performance),
|
||||
{ label: 'synthesis', phase: 'synthesise' },
|
||||
);
|
||||
log('review-code: completed');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
output: report,
|
||||
reviews: { correctness, security, performance },
|
||||
};
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function generateFindIssuesScript(_args?: Record<string, unknown>): string {
|
||||
return `
|
||||
export const meta = {
|
||||
name: 'find-issues',
|
||||
description: 'Iterative issue discovery — keep surfacing issues until consecutive rounds find nothing new.',
|
||||
phases: [
|
||||
{ title: 'Analyse', detail: 'Analyse the codebase for issues' },
|
||||
{ title: 'Check dry', detail: 'Verify no new issues remain' },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function main(args) {
|
||||
const target = args?.target ?? args?.path ?? '.';
|
||||
const maxRounds = args?.maxRounds ?? 5;
|
||||
log('find-issues: starting on ' + target + ' (max ' + maxRounds + ' rounds)');
|
||||
|
||||
const allIssues = [];
|
||||
let dryRounds = 0;
|
||||
let round = 0;
|
||||
|
||||
while (dryRounds < 2 && round < maxRounds) {
|
||||
round++;
|
||||
phase('Analyse');
|
||||
|
||||
const context = allIssues.length > 0
|
||||
? 'Previously found issues (exclude these):\\n' + JSON.stringify(allIssues)
|
||||
: 'No issues found yet.';
|
||||
|
||||
const newIssues = await agent(
|
||||
'Analyse ' + target + ' for bugs, code smells, and anti-patterns.\\n' + context + '\\nReturn a JSON array of issues. If none found, return an empty array.',
|
||||
{ label: 'round-' + round + '-analysis', phase: 'analyse' },
|
||||
);
|
||||
|
||||
let parsed: unknown[] = [];
|
||||
try {
|
||||
if (typeof newIssues === 'string') {
|
||||
parsed = JSON.parse(newIssues);
|
||||
} else if (Array.isArray(newIssues)) {
|
||||
parsed = newIssues;
|
||||
}
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
|
||||
if (parsed.length === 0) {
|
||||
dryRounds++;
|
||||
phase('Check dry');
|
||||
log('Round ' + round + ': no new issues found (dry run ' + dryRounds + '/2)');
|
||||
} else {
|
||||
dryRounds = 0;
|
||||
for (const issue of parsed) {
|
||||
allIssues.push(issue);
|
||||
}
|
||||
log('Round ' + round + ': found ' + parsed.length + ' new issue(s)');
|
||||
}
|
||||
}
|
||||
|
||||
log('find-issues: completed after ' + round + ' rounds, ' + allIssues.length + ' total issues');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
output: allIssues,
|
||||
totalRounds: round,
|
||||
totalIssues: allIssues.length,
|
||||
};
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All built-in workflow definitions shipped with BooCode.
|
||||
*/
|
||||
const BUILTIN_WORKFLOWS: BuiltinWorkflow[] = [
|
||||
{
|
||||
name: 'deep-research',
|
||||
description:
|
||||
'Performs multi-phase deep research: scope the question, search web sources in parallel, fetch full content, verify findings, and synthesise a structured report.',
|
||||
phases: [
|
||||
{ title: 'Scope', detail: 'Define the research question and search criteria' },
|
||||
{ title: 'Search', detail: 'Query web sources in parallel' },
|
||||
{ title: 'Fetch', detail: 'Retrieve full content from top sources' },
|
||||
{ title: 'Verify', detail: 'Cross-reference and validate findings' },
|
||||
{ title: 'Synthesise', detail: 'Produce a final structured report' },
|
||||
],
|
||||
generateScript: generateDeepResearchScript,
|
||||
},
|
||||
{
|
||||
name: 'review-code',
|
||||
description:
|
||||
'Multi-perspective code review that analyses code for correctness, security vulnerabilities, and performance issues in parallel, then merges findings into a unified severity-ranked report.',
|
||||
phases: [
|
||||
{ title: 'Correctness', detail: 'Check logic, edge cases, and correctness' },
|
||||
{ title: 'Security', detail: 'Analyse for vulnerabilities and unsafe patterns' },
|
||||
{ title: 'Performance', detail: 'Identify performance bottlenecks' },
|
||||
{ title: 'Synthesise', detail: 'Merge perspectives into a unified report' },
|
||||
],
|
||||
generateScript: generateReviewCodeScript,
|
||||
},
|
||||
{
|
||||
name: 'find-issues',
|
||||
description:
|
||||
'Iterative issue discovery that runs analysis rounds until two consecutive passes find nothing new, ensuring comprehensive coverage without infinite loops.',
|
||||
phases: [
|
||||
{ title: 'Analyse', detail: 'Analyse the codebase for issues' },
|
||||
{ title: 'Check dry', detail: 'Verify no new issues remain' },
|
||||
],
|
||||
generateScript: generateFindIssuesScript,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Read-only map of built-in workflows keyed by name.
|
||||
*/
|
||||
const BUILTIN_WORKFLOW_MAP = new Map<string, BuiltinWorkflow>(
|
||||
BUILTIN_WORKFLOWS.map((w) => [w.name, w]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Return all built-in workflow definitions.
|
||||
*/
|
||||
export function getBuiltinWorkflows(): BuiltinWorkflow[] {
|
||||
return BUILTIN_WORKFLOWS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a built-in workflow by name.
|
||||
*
|
||||
* @param name - Workflow name (e.g. 'deep-research').
|
||||
* @returns The built-in workflow, or undefined if not found.
|
||||
*/
|
||||
export function getBuiltinWorkflow(name: string): BuiltinWorkflow | undefined {
|
||||
return BUILTIN_WORKFLOW_MAP.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge built-in workflow metadata into a list of file-discovered workflow
|
||||
* entries. Built-in entries take precedence — if a user has a file-based
|
||||
* workflow with the same name, the built-in version wins.
|
||||
*
|
||||
* @param fileWorkflows - Workflow metadata discovered from the filesystem.
|
||||
* @returns Merged array with built-in workflows injected and duplicate names
|
||||
* resolved (built-in wins).
|
||||
*/
|
||||
export function mergeBuiltinWorkflows(
|
||||
fileWorkflows: Array<{ name: string; description: string; sourceFile?: string }>,
|
||||
): Array<{ name: string; description: string; sourceFile?: string }> {
|
||||
const seen = new Set<string>();
|
||||
const result: Array<{ name: string; description: string; sourceFile?: string }> = [];
|
||||
|
||||
// Built-in workflows first (they take precedence)
|
||||
for (const builtin of BUILTIN_WORKFLOWS) {
|
||||
seen.add(builtin.name);
|
||||
result.push({
|
||||
name: builtin.name,
|
||||
description: builtin.description,
|
||||
// No sourceFile — built-in workflows are generated, not read from disk
|
||||
});
|
||||
}
|
||||
|
||||
// File-discovered workflows — skip any name already claimed by built-in
|
||||
for (const fw of fileWorkflows) {
|
||||
if (seen.has(fw.name)) continue;
|
||||
seen.add(fw.name);
|
||||
result.push(fw);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
134
apps/server/src/services/workflow/discovery.ts
Normal file
134
apps/server/src/services/workflow/discovery.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// v2.8.0: Workflow file discovery — walks project-local and global workflow
|
||||
// directories to find runnable scripts. Built-in workflows from the catalog
|
||||
// are merged into the results (they take precedence over user-defined files).
|
||||
// All functions exported for testing.
|
||||
|
||||
import { readdirSync, existsSync } from 'node:fs';
|
||||
import { join, basename, extname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { getBuiltinWorkflows, getBuiltinWorkflow } from './catalog.js';
|
||||
|
||||
/**
|
||||
* Sentinel prefix used in `sourceFile` for built-in workflows from the
|
||||
* catalog so callers (e.g. WorkflowManager) can detect and handle them
|
||||
* by calling `generateScript()` instead of reading a file from disk.
|
||||
*/
|
||||
const BUILTIN_PREFIX = 'builtin:';
|
||||
|
||||
/**
|
||||
* Metadata about a discovered workflow file (or built-in workflow).
|
||||
*/
|
||||
export interface WorkflowMeta {
|
||||
/** Workflow name (file stem without .js extension). */
|
||||
name: string;
|
||||
/** Description loaded from the workflow module's `meta.description`.
|
||||
* Empty string until loadWorkflowMeta() resolves it. */
|
||||
description: string;
|
||||
/** Absolute path to the .js file.
|
||||
* For built-in workflows this is `'builtin:<name>'` — the caller
|
||||
* should use `getBuiltinWorkflow(name)` and `generateScript()`
|
||||
* instead of reading this path from disk. */
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a `WorkflowMeta.sourceFile` points to a built-in workflow
|
||||
* (rather than a file on disk).
|
||||
*
|
||||
* @param meta - The workflow metadata to check.
|
||||
*/
|
||||
export function isBuiltinWorkflow(meta: WorkflowMeta): boolean {
|
||||
return meta.sourceFile.startsWith(BUILTIN_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all workflow .js files in the standard search paths, merged with
|
||||
* built-in workflows from the catalog.
|
||||
*
|
||||
* Priority order (first match wins for same-named workflows):
|
||||
* 1. Built-in catalog (always takes precedence)
|
||||
* 2. <projectRoot>/.boocode/workflows/ (project-local)
|
||||
* 3. ~/.boocode/workflows/ (global, per-user)
|
||||
*
|
||||
* @param projectRoot - Absolute path to the current project root.
|
||||
*/
|
||||
export function discoverWorkflows(projectRoot: string): WorkflowMeta[] {
|
||||
const seen = new Set<string>();
|
||||
const results: WorkflowMeta[] = [];
|
||||
|
||||
// 1. Built-in workflows (highest priority)
|
||||
for (const builtin of getBuiltinWorkflows()) {
|
||||
seen.add(builtin.name);
|
||||
results.push({
|
||||
name: builtin.name,
|
||||
description: builtin.description,
|
||||
sourceFile: `${BUILTIN_PREFIX}${builtin.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Project-local + global file-based workflows
|
||||
const dirs = [
|
||||
join(projectRoot, '.boocode', 'workflows'),
|
||||
join(homedir(), '.boocode', 'workflows'),
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
try {
|
||||
const entries = readdirSync(dir);
|
||||
for (const f of entries) {
|
||||
if (!f.endsWith('.js')) continue;
|
||||
const name = basename(f, '.js');
|
||||
if (seen.has(name)) continue; // built-in shadows project-local,
|
||||
// project-local shadows global
|
||||
seen.add(name);
|
||||
results.push({
|
||||
name,
|
||||
description: '',
|
||||
sourceFile: join(dir, f),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Permission error on directory — skip silently
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single workflow by name across built-in catalog and search paths.
|
||||
*
|
||||
* Priority: built-in > project-local > global.
|
||||
*
|
||||
* @param name - Workflow name (without .js extension).
|
||||
* @param projectRoot - Absolute path to the current project root.
|
||||
*/
|
||||
export function findWorkflow(
|
||||
name: string,
|
||||
projectRoot: string,
|
||||
): WorkflowMeta | undefined {
|
||||
// Check built-in catalog first
|
||||
const builtin = getBuiltinWorkflow(name);
|
||||
if (builtin) {
|
||||
return {
|
||||
name: builtin.name,
|
||||
description: builtin.description,
|
||||
sourceFile: `${BUILTIN_PREFIX}${builtin.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to file-based discovery
|
||||
return discoverWorkflows(projectRoot).find((w) => w.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a candidate workflow file path.
|
||||
* Checks that the file exists and has a .js extension.
|
||||
*
|
||||
* @param filePath - Absolute path to check.
|
||||
*/
|
||||
export function isValidWorkflowPath(filePath: string): boolean {
|
||||
return extname(filePath) === '.js' && existsSync(filePath);
|
||||
}
|
||||
54
apps/server/src/services/workflow/index.ts
Normal file
54
apps/server/src/services/workflow/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// v2.8.0: Dynamic Workflow Engine — public surface.
|
||||
//
|
||||
// Re-exports all types and classes from the workflow sub-modules so consumers
|
||||
// import from a single entry point:
|
||||
//
|
||||
// ```typescript
|
||||
// import { WorkflowManager } from './services/workflow/index.js';
|
||||
// ```
|
||||
|
||||
export { WorkflowManager } from './manager.js';
|
||||
export type { WorkflowMetaInfo } from './manager.js';
|
||||
export type { WorkflowEventHandler } from './manager.js';
|
||||
|
||||
export { discoverWorkflows, findWorkflow, isValidWorkflowPath, isBuiltinWorkflow } from './discovery.js';
|
||||
export type { WorkflowMeta } from './discovery.js';
|
||||
|
||||
export {
|
||||
loadWorkflowScript,
|
||||
loadWorkflowScriptFromCode,
|
||||
executeWorkflowScript,
|
||||
executeWorkflowScriptFromCode,
|
||||
buildSandbox,
|
||||
transformEsmToCjs,
|
||||
isEsmSyntax,
|
||||
} from './sandbox.js';
|
||||
|
||||
export {
|
||||
getBuiltinWorkflows,
|
||||
getBuiltinWorkflow,
|
||||
mergeBuiltinWorkflows,
|
||||
fingerprintAgentTask,
|
||||
} from './catalog.js';
|
||||
export type { BuiltinWorkflow } from './catalog.js';
|
||||
|
||||
export {
|
||||
cacheKey,
|
||||
getCachedResult,
|
||||
setCachedResult,
|
||||
invalidateRun,
|
||||
clearCache,
|
||||
cacheSize,
|
||||
} from './resumability.js';
|
||||
export type { CachedResult } from './resumability.js';
|
||||
|
||||
export type {
|
||||
WorkflowScript,
|
||||
WorkflowScriptMeta,
|
||||
WorkflowContext,
|
||||
AgentTaskSpec,
|
||||
AgentTaskResult,
|
||||
WorkflowRun,
|
||||
WorkflowRunStatus,
|
||||
WorkflowEvent,
|
||||
} from './types.js';
|
||||
659
apps/server/src/services/workflow/manager.ts
Normal file
659
apps/server/src/services/workflow/manager.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
// v2.8.0: WorkflowManager — ties discovery, sandbox, and inference dispatch
|
||||
// together into a single orchestrator for multi-agent workflow scripts.
|
||||
//
|
||||
// Creates isolated sessions+chats for each agent() call within a workflow,
|
||||
// dispatches inference via the existing pipeline, polls for completion, and
|
||||
// returns structured results. All failures are returned as errors rather than
|
||||
// thrown exceptions (catch-safe API).
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { Config } from '../../config.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Broker } from '../broker.js';
|
||||
import type { UserStreamFrame } from '../../types/api.js';
|
||||
import type {
|
||||
WorkflowRun,
|
||||
WorkflowRunStatus,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
AgentTaskSpec,
|
||||
AgentTaskResult,
|
||||
WorkflowScriptMeta,
|
||||
} from './types.js';
|
||||
import { discoverWorkflows, findWorkflow, isBuiltinWorkflow } from './discovery.js';
|
||||
import { getBuiltinWorkflow } from './catalog.js';
|
||||
import { cacheKey, getCachedResult, setCachedResult } from './resumability.js';
|
||||
import {
|
||||
executeWorkflowScript,
|
||||
executeWorkflowScriptFromCode,
|
||||
isEsmSyntax,
|
||||
transformEsmToCjs,
|
||||
} from './sandbox.js';
|
||||
import { runInference } from '../inference/index.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
/**
|
||||
* Maximum time to wait for a single agent task to complete (5 minutes).
|
||||
* Beyond this, the task is treated as failed/timed out.
|
||||
*/
|
||||
const AGENT_TASK_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Polling interval when waiting for an agent task to finish.
|
||||
*/
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Maximum time for the entire workflow run (30 minutes).
|
||||
*/
|
||||
const WORKFLOW_TIMEOUT_MS = 1_800_000;
|
||||
|
||||
/**
|
||||
* Token budget tracker. Tracks total token spend across agent calls.
|
||||
*/
|
||||
class BudgetTracker {
|
||||
total: number | null;
|
||||
#spent = 0;
|
||||
|
||||
constructor(total: number | null) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
spend(amount: number): void {
|
||||
this.#spent += amount;
|
||||
}
|
||||
|
||||
spent(): number {
|
||||
return this.#spent;
|
||||
}
|
||||
|
||||
remaining(): number {
|
||||
if (this.total === null) return Infinity;
|
||||
return Math.max(0, this.total - this.#spent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a no-op bounded publish function that avoids WS dependency
|
||||
* for background workflow agent tasks. Messages are still persisted to DB.
|
||||
*/
|
||||
function noopPublish(): void {
|
||||
/* intentional no-op */
|
||||
}
|
||||
|
||||
function noopPublishUser(): void {
|
||||
/* intentional no-op */
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback type for workflow lifecycle events.
|
||||
*/
|
||||
export type WorkflowEventHandler = (event: WorkflowEvent) => void;
|
||||
|
||||
/**
|
||||
* WorkflowManager — the orchestrator for sandboxed multi-agent workflows.
|
||||
*/
|
||||
export class WorkflowManager {
|
||||
/** Active workflow runs by run ID. */
|
||||
readonly #runs = new Map<string, WorkflowRunState>();
|
||||
/** Registered event listeners. */
|
||||
readonly #listeners = new Set<WorkflowEventHandler>();
|
||||
|
||||
constructor(
|
||||
private sql: Sql,
|
||||
private config: Config,
|
||||
private log: FastifyBaseLogger,
|
||||
private projectRoot: string,
|
||||
private projectId: string,
|
||||
private broker: Broker,
|
||||
) {}
|
||||
|
||||
// ---- public API ----
|
||||
|
||||
/**
|
||||
* Discover all available workflow scripts.
|
||||
*/
|
||||
listWorkflows(): WorkflowMetaInfo[] {
|
||||
return discoverWorkflows(this.projectRoot).map((m) => ({
|
||||
name: m.name,
|
||||
sourceFile: m.sourceFile,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific workflow by name.
|
||||
*/
|
||||
getWorkflow(name: string): WorkflowMetaInfo | undefined {
|
||||
const found = findWorkflow(name, this.projectRoot);
|
||||
if (!found) return undefined;
|
||||
return { name: found.name, sourceFile: found.sourceFile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the metadata (name, description, phases) from a workflow file
|
||||
* without executing it.
|
||||
*
|
||||
* @param name - Workflow name.
|
||||
* @returns The script's meta, or undefined if not found.
|
||||
*/
|
||||
async loadWorkflowMeta(name: string): Promise<WorkflowScriptMeta | undefined> {
|
||||
const found = findWorkflow(name, this.projectRoot);
|
||||
if (!found) return undefined;
|
||||
|
||||
// Built-in workflows: return meta directly from the catalog
|
||||
if (isBuiltinWorkflow(found)) {
|
||||
const builtin = getBuiltinWorkflow(name);
|
||||
if (!builtin) return { name, description: '' };
|
||||
return {
|
||||
name: builtin.name,
|
||||
description: builtin.description,
|
||||
phases: builtin.phases,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Load meta by executing the script in a throwaway context
|
||||
const context = this.#createMinimalContext('meta-loader');
|
||||
const code = readFileSync(found.sourceFile, 'utf8');
|
||||
const finalCode = isEsmSyntax(code) ? transformEsmToCjs(code) : code;
|
||||
|
||||
const sandboxData: Record<string, unknown> & {
|
||||
module: { exports: Record<string, unknown> };
|
||||
} = {
|
||||
...context,
|
||||
console: { log: () => {} },
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
};
|
||||
vm.createContext(sandboxData as unknown as vm.Context);
|
||||
new vm.Script(finalCode).runInContext(sandboxData as unknown as vm.Context, {
|
||||
timeout: 10_000,
|
||||
filename: found.sourceFile,
|
||||
});
|
||||
|
||||
const meta = sandboxData.module.exports.meta as WorkflowScriptMeta | undefined;
|
||||
return meta ?? { name, description: '' };
|
||||
} catch {
|
||||
return { name, description: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow by name.
|
||||
*
|
||||
* @param name - The workflow name (without .js extension).
|
||||
* @param args - Optional arguments to pass to the workflow function.
|
||||
* @returns The run ID for tracking.
|
||||
*/
|
||||
async runWorkflow(
|
||||
name: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<{ runId: string }> {
|
||||
const found = findWorkflow(name, this.projectRoot);
|
||||
if (!found) {
|
||||
throw new Error(`Workflow not found: "${name}". ` +
|
||||
`Check .boocode/workflows/ or ~/.boocode/workflows/ for a ${name}.js file.`);
|
||||
}
|
||||
|
||||
const runId = randomUUID();
|
||||
const startedAt = new Date().toISOString();
|
||||
const state: WorkflowRunState = {
|
||||
id: runId,
|
||||
name,
|
||||
status: 'running',
|
||||
startedAt,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
this.#runs.set(runId, state);
|
||||
this.#emit({ type: 'run_started', runId, name });
|
||||
|
||||
// Run asynchronously — caller receives the runId immediately.
|
||||
void this.#executeRun(state, found.sourceFile, args ?? {});
|
||||
|
||||
return { runId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of a workflow run.
|
||||
*/
|
||||
getRunStatus(runId: string): WorkflowRun | undefined {
|
||||
const state = this.#runs.get(runId);
|
||||
if (!state) return undefined;
|
||||
return {
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
status: state.status,
|
||||
started_at: state.startedAt,
|
||||
finished_at: state.finishedAt,
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running workflow. Best-effort — agent tasks in-flight will be
|
||||
* aborted via AbortSignal.
|
||||
*
|
||||
* @param runId - The workflow run ID.
|
||||
* @returns true if the workflow was found and cancelled.
|
||||
*/
|
||||
cancelRun(runId: string): boolean {
|
||||
const state = this.#runs.get(runId);
|
||||
if (!state || state.status !== 'running') return false;
|
||||
state.status = 'cancelled';
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.abortController.abort();
|
||||
this.#emit({ type: 'run_cancelled', runId, name: state.name });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to workflow lifecycle events.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onEvent(handler: WorkflowEventHandler): () => void {
|
||||
this.#listeners.add(handler);
|
||||
return () => {
|
||||
this.#listeners.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
// ---- internal execution ----
|
||||
|
||||
/**
|
||||
* Execute the workflow script in the sandbox.
|
||||
*/
|
||||
async #executeRun(
|
||||
state: WorkflowRunState,
|
||||
sourceFile: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const BULTIN_MARKER = 'builtin:';
|
||||
const budgetTracker = new BudgetTracker(null); // no fixed total yet
|
||||
const runId = state.id;
|
||||
|
||||
try {
|
||||
const context: WorkflowContext = {
|
||||
agent: (prompt, opts) =>
|
||||
this.#handleAgentCall(runId, prompt, opts ?? { prompt }, state.abortController.signal),
|
||||
parallel: (thunks) =>
|
||||
Promise.all(thunks.map((t) => t())),
|
||||
pipeline: async (items, ...stages) => {
|
||||
let result = [...items];
|
||||
for (const stage of stages) {
|
||||
result = await Promise.all(result.map(stage));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
phase: (title) => {
|
||||
this.#emit({ type: 'phase', runId, title });
|
||||
},
|
||||
log: (message) => {
|
||||
this.#emit({ type: 'log', runId, message });
|
||||
},
|
||||
budget: {
|
||||
total: budgetTracker.total,
|
||||
spent: () => budgetTracker.spent(),
|
||||
remaining: () => budgetTracker.remaining(),
|
||||
},
|
||||
args,
|
||||
workflow: (nestedName, nestedArgs) =>
|
||||
this.#handleNestedWorkflow(runId, nestedName, nestedArgs ?? {}, state.abortController.signal),
|
||||
};
|
||||
|
||||
let result: unknown;
|
||||
if (sourceFile.startsWith(BULTIN_MARKER)) {
|
||||
// Built-in workflow: generate script from catalog and execute
|
||||
const workflowName = sourceFile.slice(BULTIN_MARKER.length);
|
||||
const builtin = getBuiltinWorkflow(workflowName);
|
||||
if (!builtin) {
|
||||
throw new Error(`Built-in workflow "${workflowName}" not found in catalog`);
|
||||
}
|
||||
const scriptCode = builtin.generateScript(args);
|
||||
result = await executeWorkflowScriptFromCode(scriptCode, context, args, sourceFile);
|
||||
} else {
|
||||
result = await executeWorkflowScript(sourceFile, context, args);
|
||||
}
|
||||
|
||||
// Only update to completed if we haven't been cancelled mid-flight.
|
||||
if (state.status !== 'cancelled') {
|
||||
state.status = 'completed';
|
||||
state.finishedAt = new Date().toISOString();
|
||||
}
|
||||
// Store result
|
||||
state.result = result;
|
||||
this.#emit({ type: 'run_completed', runId, name: state.name });
|
||||
} catch (err) {
|
||||
if (state.status === 'cancelled') return; // already handled
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
state.status = 'failed';
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.error = message;
|
||||
this.#emit({ type: 'run_failed', runId, name: state.name, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an `agent()` call from within a workflow.
|
||||
* Creates a session + chat, dispatches inference, polls for completion.
|
||||
*/
|
||||
async #handleAgentCall(
|
||||
runId: string,
|
||||
prompt: string,
|
||||
spec: AgentTaskSpec,
|
||||
signal: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const label = spec.label ?? `agent-${prompt.slice(0, 40).replace(/\s+/g, '_')}`;
|
||||
|
||||
this.#emit({ type: 'agent_task_started', runId, label });
|
||||
|
||||
try {
|
||||
const result = await this.executeAgentTask(prompt, spec, signal);
|
||||
this.#emit({ type: 'agent_task_completed', runId, label });
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.#emit({ type: 'agent_task_completed', runId, label });
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
output: null,
|
||||
error: message,
|
||||
} satisfies AgentTaskResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core agent task execution: create session/chat, dispatch inference, poll.
|
||||
*
|
||||
* Exported as a public method for testing.
|
||||
*/
|
||||
async executeAgentTask(
|
||||
prompt: string,
|
||||
spec: AgentTaskSpec,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
// ---- 0. Check resumability cache before creating a new task ----
|
||||
const cacheKeyStr = cacheKey(spec, '');
|
||||
const cached = getCachedResult(cacheKeyStr);
|
||||
if (cached) {
|
||||
return { ...cached, cached: true } satisfies AgentTaskResult;
|
||||
}
|
||||
|
||||
const model = spec.model ?? null;
|
||||
|
||||
// ---- 1. Create a session for this agent task ----
|
||||
const sessionName = `workflow-agent-${spec.label ?? 'task'}`;
|
||||
const sessionResult = await this.sql.begin(async (tx) => {
|
||||
const [session] = await tx<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model)
|
||||
VALUES (${this.projectId}, ${sessionName}, ${model ?? 'qwen3.6-35b-a3b-mxfp4'})
|
||||
RETURNING id
|
||||
`;
|
||||
if (!session) throw new Error('Failed to create workflow agent session');
|
||||
return session;
|
||||
});
|
||||
const sessionId = sessionResult.id;
|
||||
|
||||
// ---- 2. Create a chat in this session ----
|
||||
const chatResult = await this.sql.begin(async (tx) => {
|
||||
const [chat] = await tx<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name)
|
||||
VALUES (${sessionId}, ${spec.label ?? null})
|
||||
RETURNING id
|
||||
`;
|
||||
if (!chat) throw new Error('Failed to create workflow agent chat');
|
||||
return chat;
|
||||
});
|
||||
const chatId = chatResult.id;
|
||||
|
||||
// ---- 3. Insert user message + streaming assistant message ----
|
||||
const { userMessageId, assistantMessageId } = await this.sql.begin(async (tx) => {
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${prompt}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
return {
|
||||
userMessageId: userMsg!.id,
|
||||
assistantMessageId: assistantMsg!.id,
|
||||
};
|
||||
});
|
||||
|
||||
// ---- 4. Dispatch inference ----
|
||||
// Create a bounded InferenceContext that won't crash on missing WS
|
||||
const ctx: import('../inference/types.js').InferenceContext = {
|
||||
sql: this.sql,
|
||||
config: this.config,
|
||||
log: this.log,
|
||||
publish: noopPublish as unknown as import('../inference/types.js').FramePublisher,
|
||||
publishUser: noopPublishUser as unknown as (frame: UserStreamFrame) => void,
|
||||
broker: this.broker,
|
||||
};
|
||||
|
||||
// Create a merged signal (workflow cancellation + optional caller signal)
|
||||
const mergedController = new AbortController();
|
||||
const onAbort = () => mergedController.abort();
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const inferencePromise = runInference(
|
||||
ctx,
|
||||
sessionId,
|
||||
chatId,
|
||||
assistantMessageId,
|
||||
mergedController.signal,
|
||||
).finally(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
});
|
||||
|
||||
// ---- 5. Poll for completion ----
|
||||
try {
|
||||
const result = await this.#pollForCompletion(
|
||||
chatId,
|
||||
assistantMessageId,
|
||||
inferencePromise,
|
||||
mergedController.signal,
|
||||
);
|
||||
|
||||
// Cache successful results for resumability
|
||||
if (typeof result === 'object' && result !== null && (result as Record<string, unknown>).ok === true) {
|
||||
setCachedResult(cacheKeyStr, {
|
||||
ok: true,
|
||||
output: (result as Record<string, unknown>).output,
|
||||
token_usage: (result as Record<string, unknown>).token_usage as
|
||||
| { prompt: number; completion: number }
|
||||
| undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if ((err as Error)?.message === 'cancelled') {
|
||||
return { ok: false, output: null, error: 'Task was cancelled' } satisfies AgentTaskResult;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
output: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
} satisfies AgentTaskResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the messages table until the assistant message status changes
|
||||
* from 'streaming' to 'complete' / 'failed' / 'cancelled'.
|
||||
*/
|
||||
async #pollForCompletion(
|
||||
chatId: string,
|
||||
assistantMessageId: string,
|
||||
inferencePromise: Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
// Wait for either inference to finish or timeout
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Agent task timed out after ${AGENT_TASK_TIMEOUT_MS}ms`));
|
||||
}, AGENT_TASK_TIMEOUT_MS);
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('cancelled'));
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
// Poll loop — runs until inference completes, timeout, or cancellation
|
||||
const pollLoop = (async () => {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
|
||||
const rows = await this.sql<{
|
||||
status: string;
|
||||
content: string;
|
||||
tool_calls: unknown;
|
||||
tokens_used: number | null;
|
||||
}[]>`
|
||||
SELECT m.status, m.content, m.role,
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
|
||||
m.tokens_used
|
||||
FROM messages m
|
||||
WHERE m.id = ${assistantMessageId}
|
||||
`;
|
||||
|
||||
const msg = rows[0];
|
||||
if (!msg) {
|
||||
throw new Error(`Assistant message ${assistantMessageId} not found`);
|
||||
}
|
||||
|
||||
if (msg.status === 'complete') {
|
||||
return {
|
||||
ok: true,
|
||||
output: msg.content,
|
||||
token_usage: msg.tokens_used ? { prompt: 0, completion: msg.tokens_used } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.status === 'failed' || msg.status === 'cancelled') {
|
||||
return {
|
||||
ok: false,
|
||||
output: msg.content || null,
|
||||
error: `Assistant message ended with status: ${msg.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Still streaming — continue polling
|
||||
}
|
||||
})();
|
||||
|
||||
// Race: polling vs timeout vs inference error vs cancellation
|
||||
try {
|
||||
return await Promise.race([pollLoop, timeout]);
|
||||
} finally {
|
||||
// Ensure inference is settled (but don't block on it)
|
||||
inferencePromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a nested `workflow()` call from within a workflow.
|
||||
* Runs the named workflow with the given args and returns its result.
|
||||
*/
|
||||
async #handleNestedWorkflow(
|
||||
parentRunId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const found = findWorkflow(name, this.projectRoot);
|
||||
if (!found) {
|
||||
return { ok: false, output: null, error: `Nested workflow not found: "${name}"` };
|
||||
}
|
||||
|
||||
const nestedRunId = randomUUID();
|
||||
const startedAt = new Date().toISOString();
|
||||
const nestedState: WorkflowRunState = {
|
||||
id: nestedRunId,
|
||||
name,
|
||||
status: 'running',
|
||||
startedAt,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
this.#runs.set(nestedRunId, nestedState);
|
||||
this.#emit({ type: 'run_started', runId: nestedRunId, name });
|
||||
|
||||
// Link parent cancellation to nested
|
||||
signal.addEventListener('abort', () => {
|
||||
nestedState.abortController.abort();
|
||||
}, { once: true });
|
||||
|
||||
await this.#executeRun(nestedState, found.sourceFile, args);
|
||||
|
||||
if (nestedState.status === 'cancelled') {
|
||||
return { ok: false, output: null, error: 'Nested workflow cancelled' };
|
||||
}
|
||||
if (nestedState.status === 'failed') {
|
||||
return { ok: false, output: null, error: nestedState.error };
|
||||
}
|
||||
return { ok: true, output: nestedState.result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal WorkflowContext for non-execution purposes
|
||||
* (e.g. loading meta).
|
||||
*/
|
||||
#createMinimalContext(runId: string): Record<string, unknown> {
|
||||
return {
|
||||
agent: () => Promise.reject(new Error('Not available in this context')),
|
||||
parallel: () => Promise.reject(new Error('Not available in this context')),
|
||||
pipeline: () => Promise.reject(new Error('Not available in this context')),
|
||||
phase: () => {},
|
||||
log: () => {},
|
||||
budget: { total: null, spent: () => 0, remaining: () => Infinity },
|
||||
args: {},
|
||||
workflow: () => Promise.reject(new Error('Not available in this context')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a workflow event to all registered listeners.
|
||||
*/
|
||||
#emit(event: WorkflowEvent): void {
|
||||
for (const handler of this.#listeners) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// Swallow listener errors — one bad listener shouldn't break others
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internal types ----
|
||||
|
||||
/**
|
||||
* Metadata returned from listWorkflows / getWorkflow.
|
||||
*/
|
||||
export interface WorkflowMetaInfo {
|
||||
name: string;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal mutable state for an active workflow run.
|
||||
*/
|
||||
interface WorkflowRunState {
|
||||
id: string;
|
||||
name: string;
|
||||
status: WorkflowRunStatus;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
result?: unknown;
|
||||
abortController: AbortController;
|
||||
}
|
||||
195
apps/server/src/services/workflow/resumability.ts
Normal file
195
apps/server/src/services/workflow/resumability.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// v2.8.0: Workflow resumability cache — SHA-256 hash-based in-memory cache
|
||||
// for completed agent task results. When a workflow re-runs, completed agents
|
||||
// with unchanged specs skip execution and return cached results.
|
||||
//
|
||||
// The cache is purely in-memory (Map). No DB persistence for v1.
|
||||
// All functions are exported for testing.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { AgentTaskSpec } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shape of a cached agent task result. Mirrors the successful fields of
|
||||
* `AgentTaskResult` without the runtime-only `cached` flag.
|
||||
*/
|
||||
export interface CachedResult {
|
||||
ok: boolean;
|
||||
output: unknown;
|
||||
error?: string;
|
||||
token_usage?: { prompt: number; completion: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal cache entry with insertion timestamp for TTL support.
|
||||
*/
|
||||
interface CacheEntry {
|
||||
result: CachedResult;
|
||||
insertedAt: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default TTL for cached entries (30 minutes).
|
||||
* After this period entries are considered stale and are evicted on access.
|
||||
*/
|
||||
const DEFAULT_TTL_MS = 1_800_000;
|
||||
|
||||
/**
|
||||
* Maximum number of entries before the cache starts evicting oldest entries.
|
||||
*/
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
/**
|
||||
* In-memory cache store: SHA-256 hash → cached result.
|
||||
*/
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a deterministic SHA-256 hash for an agent task specification.
|
||||
*
|
||||
* The hash is computed from a stable-ordered JSON serialisation of the spec
|
||||
* (prompt + options) so that identical specs always produce the same key
|
||||
* regardless of JavaScript property insertion order.
|
||||
*
|
||||
* @param spec - The agent task specification (prompt, options, etc.).
|
||||
* @param args - Additional arguments string (e.g. workflow args fingerprint).
|
||||
* @returns A 64-character hex SHA-256 digest.
|
||||
*/
|
||||
export function cacheKey(spec: AgentTaskSpec, args: string): string {
|
||||
const hash = createHash('sha256');
|
||||
|
||||
// Stable-sorted serialisation of the spec
|
||||
hash.update(stableJson(spec));
|
||||
|
||||
// Append the args fingerprint
|
||||
hash.update('\0');
|
||||
hash.update(args);
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a cached result by its cache key.
|
||||
*
|
||||
* Returns `null` when:
|
||||
* - The key doesn't exist in the cache.
|
||||
* - The cached entry has exceeded the TTL (evicted silently).
|
||||
*
|
||||
* @param key - The SHA-256 hex key returned by `cacheKey()`.
|
||||
* @returns The cached result, or `null` if not found or expired.
|
||||
*/
|
||||
export function getCachedResult(key: string): CachedResult | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
// TTL check — stale entries are evicted on access
|
||||
if (Date.now() - entry.insertedAt > DEFAULT_TTL_MS) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an agent task result in the cache.
|
||||
*
|
||||
* If the cache has reached `MAX_ENTRIES`, the oldest entry (by insertion time)
|
||||
* is evicted first. This is a simple FIFO eviction — not a full LRU — because
|
||||
* workflow runs are expected to exhibit high temporal locality (recently
|
||||
* completed steps in the current run are the most likely to be re-queried).
|
||||
*
|
||||
* @param key - The SHA-256 hex key returned by `cacheKey()`.
|
||||
* @param result - The result to cache.
|
||||
*/
|
||||
export function setCachedResult(key: string, result: CachedResult): void {
|
||||
// Evict oldest entry if at capacity
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
let oldestKey: string | undefined;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
for (const [k, entry] of cache) {
|
||||
if (entry.insertedAt < oldestTime) {
|
||||
oldestTime = entry.insertedAt;
|
||||
oldestKey = k;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(key, {
|
||||
result,
|
||||
insertedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached entries that were produced during a specific workflow
|
||||
* run. The `runKey` is matched as a prefix of the cache key — this works
|
||||
* because `cacheKey()` incorporates the args string, and the caller passes
|
||||
* a run-specific token as the `args` parameter.
|
||||
*
|
||||
* @param runKey - The run-specific key prefix to invalidate.
|
||||
*/
|
||||
export function invalidateRun(runKey: string): void {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(runKey)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire cache. Used for testing and manual reset.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current number of entries in the cache.
|
||||
* Useful for testing assertions.
|
||||
*/
|
||||
export function cacheSize(): number {
|
||||
return cache.size;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stable JSON serialisation that produces the same output string for the same
|
||||
* data regardless of JavaScript object property insertion order.
|
||||
*
|
||||
* - Object keys are sorted lexicographically.
|
||||
* - Arrays preserve their element order.
|
||||
* - Primitives are serialised via `JSON.stringify`.
|
||||
*/
|
||||
function stableJson(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
if (typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableJson).join(',')}]`;
|
||||
}
|
||||
const keys = Object.keys(value as Record<string, unknown>).sort();
|
||||
const pairs = keys.map(
|
||||
(k) =>
|
||||
`${JSON.stringify(k)}:${stableJson((value as Record<string, unknown>)[k])}`,
|
||||
);
|
||||
return `{${pairs.join(',')}}`;
|
||||
}
|
||||
284
apps/server/src/services/workflow/sandbox.ts
Normal file
284
apps/server/src/services/workflow/sandbox.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// v2.8.0: VM sandbox for executing workflow scripts in an isolated Node.js
|
||||
// context with a restricted global scope. Uses Node's built-in `vm` module
|
||||
// (zero additional dependencies).
|
||||
//
|
||||
// Workflow scripts can use either CommonJS (`module.exports`) or ESM syntax
|
||||
// (`export const` / `export default`). ESM syntax is automatically transformed
|
||||
// to CJS before execution via a lightweight regex transform.
|
||||
|
||||
import vm from 'node:vm';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { WorkflowContext } from './types.js';
|
||||
|
||||
/**
|
||||
* Shared timeout for all sandboxed script execution.
|
||||
* Prevents runaway workflows from blocking the server indefinitely.
|
||||
*/
|
||||
const EXECUTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Regex-based ESM-to-CJS transform for workflow scripts.
|
||||
*
|
||||
* Handles:
|
||||
* - `export const|let|var <name> = <value>;` → `<name> = <value>;`
|
||||
* - `export default <expression>;` → `default = <expression>;`
|
||||
* - `export default function <name>(...) {...}` → `default = function <name>(...) {...}`
|
||||
* - `export { <name1>, <name2> }` → removed (inline assignment)
|
||||
*
|
||||
* @param code - Raw source code (ESM or CJS).
|
||||
* @returns Code transformed to CJS assignments suitable for vm.Script.
|
||||
*/
|
||||
export function transformEsmToCjs(code: string): string {
|
||||
// Remove `export ` prefix from declarations and `export default` assignments.
|
||||
// Order matters: handle `export default function` before bare `export default`.
|
||||
let transformed = code
|
||||
// export default async function name(...) {...} → default = async function name(...) {...}
|
||||
.replace(
|
||||
/export\s+default\s+(async\s+)?function\s*\**\s*(\w+)?\s*\(/g,
|
||||
(_, asyncKw, _name) => {
|
||||
return `default = ${asyncKw ?? ''}function ${_name ?? ''}(`;
|
||||
},
|
||||
)
|
||||
// export default class Name {...} → default = class Name {...}
|
||||
.replace(/export\s+default\s+(class\s+\w+)/g, 'default = $1')
|
||||
// export default <expression>; → default = <expression>;
|
||||
.replace(/export\s+default\s+/g, 'default = ')
|
||||
// export const|let|var name = value → name = value
|
||||
.replace(
|
||||
/export\s+(const|let|var)\s+(\w+)\s*=/g,
|
||||
(_, _decl, name) => `${name} =`,
|
||||
)
|
||||
// export function name(...) {...} → (hoisted, keep as-is but remove export)
|
||||
.replace(/^export\s+(function\s+\w+)/gm, '$1')
|
||||
// export class Name {...} → keep but remove export
|
||||
.replace(/^export\s+(class\s+\w+)/gm, '$1')
|
||||
// export { a, b, c } → (remove line)
|
||||
.replace(/^export\s+\{[^}]*\}\s*;?\s*$/gm, '')
|
||||
// export { a, b as c } → (remove line)
|
||||
.replace(/^export\s+\{[^}]*\s+as\s+\w+[^}]*\}\s*;?\s*$/gm, '');
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether code uses ESM export syntax (export keyword at line start
|
||||
* or after optional whitespace).
|
||||
*/
|
||||
export function isEsmSyntax(code: string): boolean {
|
||||
return /^\s*export\s+(const|let|var|function|class|default|\{)/m.test(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a restricted sandbox object with the workflow runtime API.
|
||||
*
|
||||
* @param context - The WorkflowContext methods to expose to the script.
|
||||
* @returns A plain object suitable for vm.createContext().
|
||||
*/
|
||||
export function buildSandbox(context: WorkflowContext): Record<string, unknown> {
|
||||
return {
|
||||
// --- Workflow API (from context) ---
|
||||
agent: context.agent,
|
||||
parallel: context.parallel,
|
||||
pipeline: context.pipeline,
|
||||
phase: context.phase,
|
||||
log: context.log,
|
||||
budget: context.budget,
|
||||
args: context.args,
|
||||
workflow: context.workflow,
|
||||
|
||||
// --- Safe built-ins ---
|
||||
console: {
|
||||
log: context.log,
|
||||
warn: context.log,
|
||||
error: context.log,
|
||||
},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: undefined, // intentionally disabled
|
||||
clearInterval: undefined, // intentionally disabled
|
||||
Promise,
|
||||
JSON,
|
||||
Math,
|
||||
Date,
|
||||
RegExp,
|
||||
Error,
|
||||
Array,
|
||||
Object,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Map,
|
||||
Set,
|
||||
WeakMap,
|
||||
WeakSet,
|
||||
parseInt,
|
||||
parseFloat,
|
||||
isNaN,
|
||||
isFinite,
|
||||
Symbol,
|
||||
BigInt,
|
||||
undefined,
|
||||
null: null,
|
||||
true: true,
|
||||
false: false,
|
||||
|
||||
// --- CommonJS interop ---
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
require: undefined, // intentionally disabled
|
||||
global: undefined, // prevent escape via `globalThis`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow script in the sandbox and return its default export
|
||||
* (the main async function).
|
||||
*
|
||||
* @param sourceFile - Absolute path to the .js workflow file.
|
||||
* @param context - The WorkflowContext to expose to the script.
|
||||
* @returns The workflow's default export function.
|
||||
* @throws {Error} If the script doesn't export a default async function,
|
||||
* or if execution fails.
|
||||
*/
|
||||
export function loadWorkflowScript(
|
||||
sourceFile: string,
|
||||
context: WorkflowContext,
|
||||
): (...args: unknown[]) => Promise<unknown> {
|
||||
const code = readFileSync(sourceFile, 'utf8');
|
||||
const finalCode = isEsmSyntax(code) ? transformEsmToCjs(code) : code;
|
||||
|
||||
const rawSandbox = buildSandbox(context);
|
||||
const sandbox = rawSandbox as Record<string, unknown> & {
|
||||
module: { exports: Record<string, unknown> };
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
|
||||
try {
|
||||
const script = new vm.Script(finalCode);
|
||||
script.runInContext(sandbox, {
|
||||
timeout: EXECUTION_TIMEOUT_MS,
|
||||
filename: sourceFile,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Workflow script execution failed: ${msg}`);
|
||||
}
|
||||
|
||||
// Check module.exports first (CJS), then sandbox.default (ESM transform)
|
||||
const exported = sandbox.module.exports.default ?? sandbox.default;
|
||||
// Also support `module.exports = async function(...)` (direct assignment)
|
||||
const mainFn =
|
||||
typeof sandbox.module.exports === 'function'
|
||||
? sandbox.module.exports
|
||||
: exported;
|
||||
|
||||
if (typeof mainFn !== 'function') {
|
||||
const exportedKeys = Object.keys({
|
||||
...sandbox.module.exports,
|
||||
...(sandbox.default ? { default: true } : {}),
|
||||
});
|
||||
throw new Error(
|
||||
`Workflow script must export a default async function. ` +
|
||||
`Found exports: ${exportedKeys.join(', ') || '(none)'}. ` +
|
||||
`Make sure your script has "export default async function main(args) {...}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return mainFn as (...args: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a workflow script from a source code string (rather than a file).
|
||||
* Useful for built-in workflows from the catalog that don't have a
|
||||
* corresponding .js file on disk.
|
||||
*
|
||||
* @param code - The JavaScript source code of the workflow.
|
||||
* @param context - The WorkflowContext to expose.
|
||||
* @param filename - Virtual filename for stack traces (e.g. 'builtin://deep-research').
|
||||
* @returns The workflow's default export function.
|
||||
* @throws {Error} If the script doesn't export a default async function.
|
||||
*/
|
||||
export function loadWorkflowScriptFromCode(
|
||||
code: string,
|
||||
context: WorkflowContext,
|
||||
filename?: string,
|
||||
): (...args: unknown[]) => Promise<unknown> {
|
||||
const finalCode = isEsmSyntax(code) ? transformEsmToCjs(code) : code;
|
||||
|
||||
const rawSandbox = buildSandbox(context);
|
||||
const sandbox = rawSandbox as Record<string, unknown> & {
|
||||
module: { exports: Record<string, unknown> };
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
|
||||
try {
|
||||
const script = new vm.Script(finalCode);
|
||||
script.runInContext(sandbox, {
|
||||
timeout: EXECUTION_TIMEOUT_MS,
|
||||
filename: filename ?? 'workflow:<anonymous>',
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Workflow script execution failed: ${msg}`);
|
||||
}
|
||||
|
||||
const exported = sandbox.module.exports.default ?? sandbox.default;
|
||||
const mainFn =
|
||||
typeof sandbox.module.exports === 'function'
|
||||
? sandbox.module.exports
|
||||
: exported;
|
||||
|
||||
if (typeof mainFn !== 'function') {
|
||||
const exportedKeys = Object.keys({
|
||||
...sandbox.module.exports,
|
||||
...(sandbox.default ? { default: true } : {}),
|
||||
});
|
||||
throw new Error(
|
||||
`Workflow script must export a default async function. ` +
|
||||
`Found exports: ${exportedKeys.join(', ') || '(none)'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return mainFn as (...args: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level convenience: load and execute a workflow script in a single call.
|
||||
*
|
||||
* @param sourceFile - Absolute path to the .js workflow file.
|
||||
* @param context - The WorkflowContext to expose.
|
||||
* @param args - Optional arguments passed to the workflow function.
|
||||
* @returns The workflow's return value.
|
||||
*/
|
||||
export async function executeWorkflowScript(
|
||||
sourceFile: string,
|
||||
context: WorkflowContext,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const mainFn = loadWorkflowScript(sourceFile, context);
|
||||
return mainFn(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow from source code (string) rather than a file.
|
||||
* Convenience wrapper around `loadWorkflowScriptFromCode`.
|
||||
*
|
||||
* @param code - The JavaScript source code of the workflow.
|
||||
* @param context - The WorkflowContext to expose.
|
||||
* @param args - Optional arguments passed to the workflow function.
|
||||
* @param filename - Virtual filename for stack traces.
|
||||
* @returns The workflow's return value.
|
||||
*/
|
||||
export async function executeWorkflowScriptFromCode(
|
||||
code: string,
|
||||
context: WorkflowContext,
|
||||
args?: Record<string, unknown>,
|
||||
filename?: string,
|
||||
): Promise<unknown> {
|
||||
const mainFn = loadWorkflowScriptFromCode(code, context, filename);
|
||||
return mainFn(args);
|
||||
}
|
||||
128
apps/server/src/services/workflow/types.ts
Normal file
128
apps/server/src/services/workflow/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// v2.8.0: Dynamic Workflow Engine — types for the sandboxed multi-agent
|
||||
// orchestration runtime. All types are exported for testing.
|
||||
|
||||
/**
|
||||
* The expected shape of a workflow script module.
|
||||
* Workflow files are plain .js files that export `meta` and `default`:
|
||||
*
|
||||
* ```js
|
||||
* export const meta = {
|
||||
* name: 'my-workflow',
|
||||
* description: 'Does something useful in phases',
|
||||
* phases: [
|
||||
* { title: 'Research', detail: 'Gather context' },
|
||||
* { title: 'Implement', detail: 'Make changes' },
|
||||
* ],
|
||||
* };
|
||||
*
|
||||
* export default async function main(args) {
|
||||
* const result = await agent('...');
|
||||
* return result;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface WorkflowScriptMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
phases?: Array<{ title: string; detail?: string }>;
|
||||
}
|
||||
|
||||
export interface WorkflowScript {
|
||||
meta: WorkflowScriptMeta;
|
||||
default: (args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for dispatching a single agent task within a workflow.
|
||||
*/
|
||||
export interface AgentTaskSpec {
|
||||
/** The instruction prompt for the agent. */
|
||||
prompt: string;
|
||||
/** Optional human-readable label for this task (shown in UI). */
|
||||
label?: string;
|
||||
/** Phase identifier for grouping tasks. */
|
||||
phase?: string;
|
||||
/** Model override (defaults to session/chat model). */
|
||||
model?: string;
|
||||
/** Zod-style JSON schema for structured output validation. */
|
||||
schema?: Record<string, unknown>;
|
||||
/** Required capabilities the agent must have. */
|
||||
capabilities?: string[];
|
||||
/** Per-agent tool-call budget ceiling. */
|
||||
max_tool_calls?: number;
|
||||
/** Per-agent step cap for the inference loop. */
|
||||
max_tool_iters?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned after an agent task completes.
|
||||
*/
|
||||
export interface AgentTaskResult {
|
||||
ok: boolean;
|
||||
output: unknown;
|
||||
error?: string;
|
||||
token_usage?: { prompt: number; completion: number };
|
||||
/** True when this result was served from the resumability cache
|
||||
* rather than re-executing the agent task. */
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context passed into every workflow script's default function.
|
||||
* Mirrors the Claude Code-compatible API surface.
|
||||
*/
|
||||
export interface WorkflowContext {
|
||||
/** Dispatch a single agent prompt. Returns the assistant's reply content. */
|
||||
agent: (prompt: string, opts?: AgentTaskSpec) => Promise<unknown>;
|
||||
/** Run multiple independent tasks concurrently. Returns results in order. */
|
||||
parallel: (thunks: Array<() => Promise<unknown>>) => Promise<unknown[]>;
|
||||
/** Pass items through a sequence of transform stages. */
|
||||
pipeline: (
|
||||
items: unknown[],
|
||||
...stages: Array<(item: unknown) => Promise<unknown>>
|
||||
) => Promise<unknown[]>;
|
||||
/** Announce the current execution phase (for UI progress). */
|
||||
phase: (title: string) => void;
|
||||
/** Emit a log message for this workflow run. */
|
||||
log: (message: string) => void;
|
||||
/** Token budget tracker for the current run. */
|
||||
budget: {
|
||||
total: number | null;
|
||||
spent: () => number;
|
||||
remaining: () => number;
|
||||
};
|
||||
/** The arguments passed when this workflow was started. */
|
||||
args: Record<string, unknown>;
|
||||
/** Call another workflow from within a workflow (nested). */
|
||||
workflow: (name: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a workflow execution run.
|
||||
*/
|
||||
export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Persistent record of a workflow run.
|
||||
*/
|
||||
export interface WorkflowRun {
|
||||
id: string;
|
||||
name: string;
|
||||
status: WorkflowRunStatus;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted by the workflow manager for subscribers.
|
||||
*/
|
||||
export type WorkflowEvent =
|
||||
| { type: 'run_started'; runId: string; name: string }
|
||||
| { type: 'run_completed'; runId: string; name: string }
|
||||
| { type: 'run_failed'; runId: string; name: string; error: string }
|
||||
| { type: 'run_cancelled'; runId: string; name: string }
|
||||
| { type: 'phase'; runId: string; title: string }
|
||||
| { type: 'log'; runId: string; message: string }
|
||||
| { type: 'agent_task_started'; runId: string; label?: string }
|
||||
| { type: 'agent_task_completed'; runId: string; label?: string };
|
||||
Reference in New Issue
Block a user