feat: !include YAML tag and folder-based workflow layout
CI / check (pull_request) Failing after 1m58s

- Add !include custom YAML tag for referencing external files (Fixes #582)
  - .md/.txt files included as strings
  - .json files parsed as JSON objects
  - .yaml/.yml files parsed as YAML objects
  - Paths resolved relative to the workflow YAML file

- Support foo/index.yaml as alternative to foo.yaml (Fixes #583)
  - Updated discoverProjectWorkflows(), findWorkflowInDir()
  - Updated workflowNameFromPath() for index.yaml detection
  - Flat files take priority over folder layout

- Added tests for both features
This commit is contained in:
2026-05-31 04:12:11 +00:00
parent 9fb817a99c
commit 88c251fc14
7 changed files with 160 additions and 14 deletions
@@ -0,0 +1,60 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "include-tag-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("!include tag", () => {
test("includes .md file as string", async () => {
await writeFile(join(tmpDir, "prompt.md"), "You are an analyst.");
const yaml = "system: !include prompt.md";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.system).toBe("You are an analyst.");
});
test("includes .json file as parsed object", async () => {
await writeFile(join(tmpDir, "schema.json"), '{"type":"object","properties":{}}');
const yaml = "outputSchema: !include schema.json";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.outputSchema).toEqual({ type: "object", properties: {} });
});
test("includes .yaml file as parsed object", async () => {
await writeFile(join(tmpDir, "config.yaml"), "key: value\nlist:\n - a\n - b");
const yaml = "config: !include config.yaml";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.config).toEqual({ key: "value", list: ["a", "b"] });
});
test("resolves relative subdirectory paths", async () => {
const subdir = join(tmpDir, "roles");
await mkdir(subdir, { recursive: true });
await writeFile(join(subdir, "analyst.md"), "Analyze data.");
const yaml = "system: !include roles/analyst.md";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.system).toBe("Analyze data.");
});
test("throws on missing file", () => {
const yaml = "system: !include nonexistent.md";
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow();
});
test("includes .txt file as string", async () => {
await writeFile(join(tmpDir, "note.txt"), "Hello world");
const yaml = "note: !include note.txt";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.note).toBe("Hello world");
});
});
@@ -257,6 +257,43 @@ describe("Strategy 3: Local Discovery", () => {
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should find workflow in folder-based layout (name/index.yaml)", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow", "solve-issue");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "index.yaml"), await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
}
});
test("should prefer flat file over folder-based layout", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue", "flat"));
const folderDir = join(workflowDir, "solve-issue");
await mkdir(folderDir, { recursive: true });
await writeFile(join(folderDir, "index.yaml"), await createWorkflowYaml("solve-issue", "folder"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (flat)");
}
});
});
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
+10 -1
View File
@@ -27,6 +27,7 @@ import type { AdapterOutput } from "@uncaged/workflow-util-agent";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import { evaluate } from "../moderator/index.js";
import {
@@ -118,6 +119,10 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
return result;
}
}
for (const indexName of ["index.yaml", "index.yml"]) {
const candidate = resolvePath(dir, ".workflow", name, indexName);
try { await access(candidate); return candidate; } catch { /* not found */ }
}
// Check .workflows/ directory as fallback (legacy)
for (const ext of [".yaml", ".yml"]) {
@@ -126,6 +131,10 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
return result;
}
}
for (const indexName of ["index.yaml", "index.yml"]) {
const candidate = resolvePath(dir, ".workflows", name, indexName);
try { await access(candidate); return candidate; } catch { /* not found */ }
}
return null;
}
@@ -172,7 +181,7 @@ async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promis
let raw: unknown;
try {
raw = parse(text) as unknown;
raw = parse(text, { customTags: [createIncludeTag(dirname(filePath))] }) as unknown;
} catch (e) {
fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
}
@@ -1,9 +1,11 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve as resolvePath } from "node:path";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
import {
createUwfStore,
@@ -123,7 +125,7 @@ export async function cmdWorkflowAdd(
let raw: unknown;
try {
raw = parse(text) as unknown;
raw = parse(text, { customTags: [createIncludeTag(dirname(resolvePath(filePath)))] }) as unknown;
} catch (e) {
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { extname, resolve } from "node:path";
import { parse as parseYaml } from "yaml";
/**
* Create a YAML customTags entry for !include that resolves file paths
* relative to the given base directory.
*/
export function createIncludeTag(baseDir: string) {
return {
tag: "!include",
resolve(str: string) {
const filePath = resolve(baseDir, str);
const content = readFileSync(filePath, "utf8");
const ext = extname(filePath).toLowerCase();
if (ext === ".json") {
return JSON.parse(content);
}
if (ext === ".yaml" || ext === ".yml") {
return parseYaml(content);
}
return content;
},
};
}
+19 -8
View File
@@ -1,4 +1,5 @@
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { access, appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import type { Dirent } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -27,9 +28,9 @@ export async function discoverProjectWorkflows(
projectRoot: string,
): Promise<ProjectWorkflowEntry[]> {
const dir = join(projectRoot, ".workflows");
let entries: string[];
let dirents: Dirent[];
try {
entries = await readdir(dir);
dirents = await readdir(dir, { withFileTypes: true });
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
@@ -39,12 +40,22 @@ export async function discoverProjectWorkflows(
}
const result: ProjectWorkflowEntry[] = [];
for (const entry of entries) {
if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) {
continue;
for (const entry of dirents) {
if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
const stem = entry.name.endsWith(".yaml") ? entry.name.slice(0, -5) : entry.name.slice(0, -4);
result.push({ name: stem, filePath: join(dir, entry.name) });
} else if (entry.isDirectory()) {
for (const indexName of ["index.yaml", "index.yml"]) {
const indexPath = join(dir, entry.name, indexName);
try {
await access(indexPath);
result.push({ name: entry.name, filePath: indexPath });
break;
} catch {
// not found, try next
}
}
}
const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4);
result.push({ name: stem, filePath: join(dir, entry) });
}
return result;
}
+6 -4
View File
@@ -1,4 +1,4 @@
import { basename } from "node:path";
import { basename, dirname } from "node:path";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
@@ -68,9 +68,11 @@ function isGraph(value: unknown): boolean {
*/
export function workflowNameFromPath(filePath: string): string {
const base = basename(filePath);
if (base.endsWith(".yaml")) return base.slice(0, -5);
if (base.endsWith(".yml")) return base.slice(0, -4);
return base;
const stem = base.endsWith(".yaml") ? base.slice(0, -5) : base.endsWith(".yml") ? base.slice(0, -4) : base;
if (stem === "index") {
return basename(dirname(filePath));
}
return stem;
}
/**