Skip to content

Commit 5a55af4

Browse files
feat(workflows): add v1 if/delay nodes
1 parent e679632 commit 5a55af4

4 files changed

Lines changed: 411 additions & 4 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, test } from 'vitest';
2+
import fs from 'node:fs/promises';
3+
import path from 'node:path';
4+
import os from 'node:os';
5+
import { evalIfCondition } from '../workflow-if';
6+
7+
async function tmpRunDir() {
8+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'clawrecipes-run-'));
9+
await fs.mkdir(path.join(dir, 'node-outputs'), { recursive: true });
10+
return dir;
11+
}
12+
13+
describe('workflow if node', () => {
14+
test('truthy on node output path', async () => {
15+
const runDir = await tmpRunDir();
16+
const outFile = path.join(runDir, 'node-outputs', '000-prev.json');
17+
await fs.writeFile(outFile, JSON.stringify({ text: JSON.stringify({ ok: true, count: 2 }) }, null, 2));
18+
19+
const res = await evalIfCondition({
20+
runDir,
21+
condition: { lhs: 'nodes.prev.output.ok', op: 'truthy' },
22+
});
23+
24+
expect(res.ok).toBe(true);
25+
expect(res.value).toBe(true);
26+
});
27+
28+
test('numeric comparator', async () => {
29+
const runDir = await tmpRunDir();
30+
const outFile = path.join(runDir, 'node-outputs', '000-prev.json');
31+
await fs.writeFile(outFile, JSON.stringify({ text: JSON.stringify({ count: 2 }) }, null, 2));
32+
33+
const res = await evalIfCondition({
34+
runDir,
35+
condition: { lhs: 'nodes.prev.output.count', op: '>=', rhs: 2 },
36+
});
37+
38+
expect(res.value).toBe(true);
39+
});
40+
41+
test('contains on string', async () => {
42+
const runDir = await tmpRunDir();
43+
const outFile = path.join(runDir, 'node-outputs', '000-prev.json');
44+
await fs.writeFile(outFile, JSON.stringify({ text: JSON.stringify({ msg: 'hello world' }) }, null, 2));
45+
46+
const res = await evalIfCondition({
47+
runDir,
48+
condition: { lhs: 'nodes.prev.output.msg', op: 'contains', rhs: 'world' },
49+
});
50+
51+
expect(res.value).toBe(true);
52+
});
53+
});

src/lib/workflows/workflow-if.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
function isRecord(v: unknown): v is Record<string, unknown> {
5+
return !!v && typeof v === 'object' && !Array.isArray(v);
6+
}
7+
8+
function asRecord(v: unknown): Record<string, unknown> {
9+
return isRecord(v) ? v : {};
10+
}
11+
12+
function asString(v: unknown, fallback = ''): string {
13+
return typeof v === 'string' ? v : v == null ? fallback : String(v);
14+
}
15+
16+
function parseDotPath(p: string): Array<string> {
17+
return p
18+
.split('.')
19+
.map((s) => s.trim())
20+
.filter(Boolean);
21+
}
22+
23+
function getByPath(obj: unknown, p: string): unknown {
24+
const parts = parseDotPath(p);
25+
let cur: unknown = obj;
26+
for (const part of parts) {
27+
if (Array.isArray(cur)) {
28+
const idx = Number(part);
29+
if (!Number.isFinite(idx)) return undefined;
30+
cur = cur[idx];
31+
continue;
32+
}
33+
if (!isRecord(cur)) return undefined;
34+
cur = (cur as Record<string, unknown>)[part];
35+
}
36+
return cur;
37+
}
38+
39+
async function findNodeOutputFile(opts: { runDir: string; nodeId: string }): Promise<string | null> {
40+
const nodeOutputsDir = path.join(opts.runDir, 'node-outputs');
41+
try {
42+
const files = await fs.readdir(nodeOutputsDir);
43+
// Pick the latest by lexical sort (prefix is numeric padded; good enough).
44+
const matches = files.filter((f) => f.endsWith(`-${opts.nodeId}.json`)).sort();
45+
const pick = matches[matches.length - 1];
46+
return pick ? path.join(nodeOutputsDir, pick) : null;
47+
} catch {
48+
return null;
49+
}
50+
}
51+
52+
export async function loadNodeOutputPayload(opts: { runDir: string; nodeId: string }): Promise<unknown> {
53+
const file = await findNodeOutputFile(opts);
54+
if (!file) return undefined;
55+
const raw = await fs.readFile(file, 'utf8');
56+
const parsed = JSON.parse(raw) as unknown;
57+
58+
// Common shape for llm/tool nodes: { text: "{...json...}" }
59+
const rec = asRecord(parsed);
60+
const text = asString(rec['text']).trim();
61+
if (text) {
62+
try {
63+
return JSON.parse(text);
64+
} catch {
65+
// fall through
66+
}
67+
}
68+
69+
return parsed;
70+
}
71+
72+
export type IfComparator =
73+
| 'truthy'
74+
| '=='
75+
| '!='
76+
| '>'
77+
| '>='
78+
| '<'
79+
| '<='
80+
| 'contains';
81+
82+
export type IfCondition = {
83+
lhs: string;
84+
op: IfComparator;
85+
rhs?: unknown;
86+
};
87+
88+
export async function evalIfCondition(opts: { runDir: string; condition: IfCondition }): Promise<{ ok: true; value: boolean; detail: Record<string, unknown> }> {
89+
const lhsRaw = asString(opts.condition.lhs).trim();
90+
const op = asString(opts.condition.op).trim() as IfComparator;
91+
const rhs = opts.condition.rhs;
92+
93+
// Supported v1 reference format:
94+
// - nodes.<nodeId>.output.<path>
95+
// Anything else is treated as a literal string (for now).
96+
let lhsValue: unknown = undefined;
97+
let source: Record<string, unknown> = { kind: 'literal', lhs: lhsRaw };
98+
99+
const m = lhsRaw.match(/^nodes\.([^.]+)\.output\.(.+)$/);
100+
if (m) {
101+
const nodeId = m[1] ?? '';
102+
const outPath = m[2] ?? '';
103+
const payload = await loadNodeOutputPayload({ runDir: opts.runDir, nodeId });
104+
lhsValue = getByPath(payload, outPath);
105+
source = { kind: 'nodeOutput', nodeId, path: outPath };
106+
} else {
107+
lhsValue = lhsRaw;
108+
}
109+
110+
let value = false;
111+
112+
if (op === 'truthy') {
113+
value = !!lhsValue;
114+
} else if (op === '==') {
115+
value = lhsValue === rhs;
116+
} else if (op === '!=') {
117+
value = lhsValue !== rhs;
118+
} else if (op === '>' || op === '>=' || op === '<' || op === '<=') {
119+
const a = typeof lhsValue === 'number' ? lhsValue : Number(lhsValue);
120+
const b = typeof rhs === 'number' ? rhs : Number(rhs);
121+
if (Number.isFinite(a) && Number.isFinite(b)) {
122+
if (op === '>') value = a > b;
123+
if (op === '>=') value = a >= b;
124+
if (op === '<') value = a < b;
125+
if (op === '<=') value = a <= b;
126+
} else {
127+
value = false;
128+
}
129+
} else if (op === 'contains') {
130+
if (typeof lhsValue === 'string') value = typeof rhs === 'string' && lhsValue.includes(rhs);
131+
else if (Array.isArray(lhsValue)) value = lhsValue.some((x) => x === rhs);
132+
else value = false;
133+
} else {
134+
// Unknown op => false.
135+
value = false;
136+
}
137+
138+
return {
139+
ok: true,
140+
value,
141+
detail: {
142+
source,
143+
op,
144+
rhs,
145+
lhsValue,
146+
},
147+
};
148+
}
149+
150+
export function lastIfValueFromRun(run: { nodeResults?: Array<Record<string, unknown>> }, nodeId: string): boolean | null {
151+
const results = Array.isArray(run.nodeResults) ? run.nodeResults : [];
152+
for (let i = results.length - 1; i >= 0; i--) {
153+
const r = asRecord(results[i]);
154+
if (asString(r['nodeId']).trim() !== nodeId) continue;
155+
if (asString(r['kind']).trim() !== 'if') continue;
156+
const v = r['value'];
157+
if (typeof v === 'boolean') return v;
158+
break;
159+
}
160+
return null;
161+
}

0 commit comments

Comments
 (0)