Skip to content

Commit a265d62

Browse files
committed
feat(workflow): add subworkflow steps
Enable composing workflows via workflow: steps with isolated child context, recursion/depth guards, and artifact exports; add CLI E2E coverage across direct/supervised transports and scope CI tests to repo suites.
1 parent 8ea38d3 commit a265d62

20 files changed

Lines changed: 2280 additions & 139 deletions

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on Keep a Changelog, and this project follows SemVer where practical.
6+
7+
## [0.1.3] - 2026-02-22
8+
9+
### Added
10+
11+
- Subworkflow steps via `workflow:`: parent workflows can invoke child workflows, with isolated child context under `_subworkflows/` and optional `exports` to copy child artifacts into the parent context.
12+
- Recursion protection for subworkflow invocations (cycle detection + max nesting depth).
13+
- CLI E2E coverage for subworkflow execution in three modes: direct, supervised (stdio transport), supervised (socket transport).
14+
15+
### Changed
16+
17+
- Agent catalog loading is consolidated in `src/workflow/workflow-loader.ts` and reused by CLI and daemon; child workflows inherit the parent's resolved catalog.
18+
- `make test` / `bun run test` are scoped to this repo's test suites (`test/unit`, `test/integration`, `tests/at`) to avoid running vendored/reference tests.
19+
20+
### Fixed
21+
22+
- Harden workflow parsing and context path handling against path traversal and reserved-name collisions (step IDs and artifact names).
23+
24+
## [0.1.2] - 2026-02-20
25+
26+
- See GitHub release notes.

‎Makefile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ all: build ## Build the binary (default)
2626
build: ## Build a single binary (roboppi)
2727
$(BUN) $(BUN_FLAGS) build $(BUN_BUILD_FLAGS) --compile $(SRC) --outfile $(BIN)
2828

29-
test: ## Run all tests
30-
$(BUN) $(BUN_FLAGS) test
29+
test: ## Run all tests (repo scope)
30+
$(BUN) $(BUN_FLAGS) test test/unit test/integration tests/at
3131

3232
tests: test ## Alias for `make test`
3333

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "roboppi",
3-
"version": "0.1.1",
3+
"version": "0.1.3",
44
"type": "module",
55
"bin": {
66
"roboppi": "src/cli.ts"
@@ -9,7 +9,7 @@
99
"start": "bun run src/cli.ts",
1010
"dev": "bun run src/index.ts",
1111
"dev:scheduler": "bun run src/scheduler/index.ts",
12-
"test": "bun test",
12+
"test": "bun test test/unit test/integration tests/at",
1313
"test:unit": "bun test test/unit",
1414
"test:integration": "bun test test/integration",
1515
"test:at": "bun test tests/at",

‎src/daemon/daemon.ts‎

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { CommandSource } from "./events/command-source.js";
1717
import { mergeEventSources } from "./events/event-source.js";
1818
import type { EventSource } from "./events/event-source.js";
1919
import { parseWorkflow } from "../workflow/parser.js";
20-
import { parseAgentCatalog, type AgentCatalog } from "../workflow/agent-catalog.js";
20+
import { loadAgentCatalog } from "../workflow/workflow-loader.js";
2121
import type { StepRunner } from "../workflow/executor.js";
2222
import { WorkflowExecutor } from "../workflow/executor.js";
2323
import { MultiWorkerStepRunner } from "../workflow/multi-worker-step-runner.js";
@@ -512,6 +512,12 @@ export class Daemon {
512512
this.shutdownAbortController.signal,
513513
branchContext,
514514
this.supervised,
515+
undefined,
516+
{
517+
definitionPath: workflowPath,
518+
workflowCallStack: [workflowPath],
519+
agentCatalog,
520+
},
515521
);
516522
const result = await executor.execute();
517523

@@ -550,52 +556,21 @@ export class Daemon {
550556
private async loadAgentCatalogForWorkflow(
551557
workflowPath: string,
552558
workflowEnv: Record<string, string>,
553-
): Promise<AgentCatalog | undefined> {
554-
const fromProcessEnv = splitPathList(
555-
process.env.ROBOPPI_AGENTS_FILE,
556-
);
559+
): Promise<import("../workflow/agent-catalog.js").AgentCatalog | undefined> {
560+
const fromProcessEnv = splitPathList(process.env.ROBOPPI_AGENTS_FILE);
557561
const fromConfig = this.config.agents_file ? [this.config.agents_file] : [];
558-
const fromWorkflowEnv = splitPathList(
559-
workflowEnv.ROBOPPI_AGENTS_FILE,
560-
);
561-
562-
// Precedence: process env (lowest) -> daemon config -> trigger env (highest).
563-
const explicit = [...fromProcessEnv, ...fromConfig, ...fromWorkflowEnv];
562+
const fromWorkflowEnv = splitPathList(workflowEnv.ROBOPPI_AGENTS_FILE);
564563

565564
const resolveFromWorkspace = (p: string): string =>
566565
path.isAbsolute(p) ? p : path.resolve(this.workspaceDir, p);
567566

568-
const candidates: string[] = [];
569-
if (explicit.length > 0) {
570-
candidates.push(...explicit.map(resolveFromWorkspace));
571-
} else {
572-
const dir = path.dirname(workflowPath);
573-
candidates.push(path.join(dir, "agents.yaml"));
574-
candidates.push(path.join(dir, "agents.yml"));
575-
}
576-
577-
let catalog: AgentCatalog | undefined;
578-
for (const p of candidates) {
579-
try {
580-
const content = await readFile(p, "utf-8");
581-
const parsed = parseAgentCatalog(content);
582-
catalog = { ...(catalog ?? {}), ...parsed };
583-
} catch (err: unknown) {
584-
const code = (err as any)?.code;
585-
586-
// In implicit mode, missing default files are ignored.
587-
if (explicit.length === 0 && code === "ENOENT") {
588-
continue;
589-
}
567+
const envAgentsPaths = fromProcessEnv.map(resolveFromWorkspace);
568+
const explicitAgentsPaths = [...fromConfig, ...fromWorkflowEnv].map(resolveFromWorkspace);
590569

591-
if (code === "ENOENT") {
592-
throw new Error(`Agent catalog not found: ${p}`);
593-
}
594-
throw err;
595-
}
596-
}
597-
598-
return catalog;
570+
return loadAgentCatalog(workflowPath, {
571+
envAgentsPaths,
572+
explicitAgentsPaths: explicitAgentsPaths.length > 0 ? explicitAgentsPaths : undefined,
573+
});
599574
}
600575

601576
private logBranchContext(triggerId: string, context: BranchRuntimeContext): void {

‎src/workflow/agent-catalog.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import YAML from "yaml";
22

33
import type { StepDefinition, DurationString } from "./types.js";
44

5-
export type AgentWorkerKind = StepDefinition["worker"];
6-
export type AgentCapability = StepDefinition["capabilities"][number];
5+
export type AgentWorkerKind = NonNullable<StepDefinition["worker"]>;
6+
export type AgentCapability = NonNullable<StepDefinition["capabilities"]>[number];
77

88
export interface AgentProfile {
99
description?: string;

‎src/workflow/context-manager.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,18 @@ export interface StepMeta {
1111
attempts: number;
1212
iterations?: number;
1313
maxIterations?: number;
14-
workerKind: string;
14+
workerKind?: string;
1515
artifacts: Array<{ name: string; path: string; type?: string }>;
1616
workerResult?: unknown;
17+
subworkflow?: {
18+
path: string;
19+
name: string;
20+
workflowId: string;
21+
status: string;
22+
contextDir: string;
23+
startedAt: number;
24+
completedAt?: number;
25+
};
1726
}
1827

1928
/**
@@ -31,9 +40,15 @@ export interface StepResolvedMeta {
3140
attempts: number;
3241
iterations?: number;
3342
maxIterations?: number;
34-
workerKind: string;
43+
workerKind?: string;
3544
artifacts: Array<{ name: string; path: string; type?: string }>;
3645
workerResult?: unknown;
46+
subworkflowCall?: {
47+
requestedPath: string;
48+
definitionPath?: string;
49+
effectiveTimeoutMs?: number;
50+
maxNestingDepth?: number;
51+
};
3752
resolved: {
3853
timeoutMs?: number;
3954
workspaceRef?: string;
@@ -46,7 +61,12 @@ export interface StepResolvedMeta {
4661
}
4762

4863
function validatePathSegment(name: string): void {
49-
if (name.includes("..") || name.includes(path.sep) || name.includes("/")) {
64+
if (
65+
name.includes("..") ||
66+
name.includes(path.sep) ||
67+
name.includes("/") ||
68+
name.includes("\\")
69+
) {
5070
throw new Error(`Invalid path segment: ${name}`);
5171
}
5272
}
@@ -66,7 +86,7 @@ function ensureWithinBase(basePath: string, targetPath: string): string {
6686
}
6787

6888
export class ContextManager {
69-
constructor(private readonly contextDir: string) {}
89+
constructor(public readonly contextDir: string) {}
7090

7191
async initWorkflow(
7292
workflowId: string,

‎src/workflow/dag-validator.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { WorkflowDefinition } from "./types.js";
2+
import { isSubworkflowStep } from "./types.js";
23

34
export interface ValidationError {
45
stepId?: string;
@@ -77,6 +78,22 @@ export function validateDag(workflow: WorkflowDefinition): ValidationError[] {
7778
});
7879
}
7980
}
81+
82+
// Subworkflow: exports `as` name uniqueness
83+
if (isSubworkflowStep(step) && step.exports) {
84+
const seen = new Set<string>();
85+
for (const exp of step.exports) {
86+
const name = exp.as ?? exp.artifact;
87+
if (seen.has(name)) {
88+
errors.push({
89+
stepId,
90+
field: "exports",
91+
message: `Step "${stepId}" has duplicate export name "${name}"`,
92+
});
93+
}
94+
seen.add(name);
95+
}
96+
}
8097
}
8198

8299
// Cycle detection via topological sort (Kahn's algorithm)

0 commit comments

Comments
 (0)