@fred-drake/anvil 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ import type {
2
+ AgentCheck,
3
+ Check,
4
+ DeterministicCheck,
5
+ Templatable,
6
+ WorkflowDefinition,
7
+ WorkflowStep,
8
+ WorkflowThinkingLevel,
9
+ } from "./types.ts";
10
+
11
+ export type ValidationResult =
12
+ | { ok: true; workflow: WorkflowDefinition }
13
+ | { ok: false; errors: string[] };
14
+
15
+ const WORKFLOW_NAME_RE = /^[a-z0-9-]+$/;
16
+ const THINKING_LEVELS = new Set<WorkflowThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh"]);
17
+
18
+ const WORKFLOW_KEYS = new Set(["name", "description", "defaults", "steps"]);
19
+ const DEFAULTS_KEYS = new Set(["delegation", "subagentTimeoutMs", "agent", "onFail", "maxLoops"]);
20
+ const STEP_KEYS = new Set([
21
+ "id",
22
+ "title",
23
+ "prompt",
24
+ "model",
25
+ "thinkingLevel",
26
+ "retryModelSelections",
27
+ "delegation",
28
+ "subagentTimeoutMs",
29
+ "agent",
30
+ "runInMain",
31
+ "skipIf",
32
+ "checks",
33
+ "onFail",
34
+ ]);
35
+ const DETERMINISTIC_CHECK_KEYS = new Set(["type", "id", "name", "command", "cwd", "timeoutMs", "onFail"]);
36
+ const AGENT_CHECK_KEYS = new Set(["type", "id", "name", "prompt", "agent", "onFail"]);
37
+ const CHECK_KEYS = new Set([...DETERMINISTIC_CHECK_KEYS, ...AGENT_CHECK_KEYS]);
38
+ const ON_FAIL_KEYS = new Set(["goto", "maxLoops", "onExhausted", "feedback"]);
39
+ const RETRY_MODEL_SELECTION_KEYS = new Set(["retry", "model", "thinkingLevel"]);
40
+
41
+ export function validateWorkflow(value: unknown): ValidationResult {
42
+ const errors: string[] = [];
43
+
44
+ if (!isRecord(value)) {
45
+ return { ok: false, errors: ["workflow must be an object"] };
46
+ }
47
+
48
+ validateKnownKeys(value, "workflow", WORKFLOW_KEYS, errors);
49
+
50
+ if (typeof value.name !== "string" || value.name.length === 0) {
51
+ errors.push("workflow.name must be a non-empty string");
52
+ } else if (!WORKFLOW_NAME_RE.test(value.name)) {
53
+ errors.push("workflow.name must match /^[a-z0-9-]+$/");
54
+ }
55
+
56
+ if (value.description !== undefined && typeof value.description !== "string") {
57
+ errors.push("workflow.description must be a string when provided");
58
+ }
59
+
60
+ const rawSteps = value.steps;
61
+ if (!Array.isArray(rawSteps) || rawSteps.length === 0) {
62
+ if (value.defaults !== undefined) validateDefaults(value.defaults, errors);
63
+ errors.push("workflow.steps must be a non-empty array");
64
+ return errors.length === 0
65
+ ? { ok: true, workflow: value as unknown as WorkflowDefinition }
66
+ : { ok: false, errors };
67
+ }
68
+
69
+ const stepIds = new Set<string>();
70
+ const duplicateIds = new Set<string>();
71
+ for (const step of rawSteps) {
72
+ if (isRecord(step) && typeof step.id === "string" && step.id.length > 0) {
73
+ if (stepIds.has(step.id)) duplicateIds.add(step.id);
74
+ stepIds.add(step.id);
75
+ }
76
+ }
77
+ for (const id of duplicateIds) errors.push(`duplicate step id "${id}"`);
78
+
79
+ if (value.defaults !== undefined) validateDefaults(value.defaults, errors, stepIds);
80
+ rawSteps.forEach((step, index) => validateStep(step, index, stepIds, errors));
81
+
82
+ return errors.length === 0
83
+ ? { ok: true, workflow: value as unknown as WorkflowDefinition }
84
+ : { ok: false, errors };
85
+ }
86
+
87
+ function validateDefaults(defaults: unknown, errors: string[], stepIds?: Set<string>): void {
88
+ if (!isRecord(defaults)) {
89
+ errors.push("workflow.defaults must be an object when provided");
90
+ return;
91
+ }
92
+ validateKnownKeys(defaults, "workflow.defaults", DEFAULTS_KEYS, errors);
93
+ if (defaults.delegation !== undefined) {
94
+ validateDelegation(defaults.delegation, "workflow.defaults.delegation", errors);
95
+ }
96
+ if (defaults.agent !== undefined && typeof defaults.agent !== "string") {
97
+ errors.push("workflow.defaults.agent must be a string when provided");
98
+ }
99
+ if (defaults.maxLoops !== undefined && !isNonNegativeInteger(defaults.maxLoops)) {
100
+ errors.push("workflow.defaults.maxLoops must be a non-negative integer when provided");
101
+ }
102
+ if (defaults.subagentTimeoutMs !== undefined && !isPositiveInteger(defaults.subagentTimeoutMs)) {
103
+ errors.push("workflow.defaults.subagentTimeoutMs must be a positive integer when provided");
104
+ }
105
+ if (defaults.onFail !== undefined) {
106
+ validateOnFailPolicy(defaults.onFail, "workflow.defaults.onFail", stepIds, errors);
107
+ }
108
+ }
109
+
110
+ function validateStep(step: unknown, index: number, stepIds: Set<string>, errors: string[]): void {
111
+ const path = `workflow.steps[${index}]`;
112
+ if (!isRecord(step)) {
113
+ errors.push(`${path} must be an object`);
114
+ return;
115
+ }
116
+
117
+ validateKnownKeys(step, path, STEP_KEYS, errors);
118
+
119
+ if (typeof step.id !== "string" || step.id.length === 0) {
120
+ errors.push(`${path}.id must be a non-empty string`);
121
+ }
122
+ if (step.title !== undefined && typeof step.title !== "string") {
123
+ errors.push(`${path}.title must be a string when provided`);
124
+ }
125
+ if (!isTemplatable(step.prompt)) {
126
+ errors.push(`${path}.prompt must be a string or function`);
127
+ }
128
+ if (step.model !== undefined && (typeof step.model !== "string" || step.model.length === 0)) {
129
+ errors.push(`${path}.model must be a non-empty string when provided`);
130
+ }
131
+ if (step.thinkingLevel !== undefined && !isThinkingLevel(step.thinkingLevel)) {
132
+ errors.push(
133
+ `${path}.thinkingLevel must be one of "off", "minimal", "low", "medium", "high", or "xhigh" when provided`,
134
+ );
135
+ }
136
+ if (step.retryModelSelections !== undefined) {
137
+ validateRetryModelSelections(step.retryModelSelections, `${path}.retryModelSelections`, errors);
138
+ }
139
+ if (step.delegation !== undefined) {
140
+ validateDelegation(step.delegation, `${path}.delegation`, errors);
141
+ }
142
+ if (step.subagentTimeoutMs !== undefined && !isPositiveInteger(step.subagentTimeoutMs)) {
143
+ errors.push(`${path}.subagentTimeoutMs must be a positive integer when provided`);
144
+ }
145
+ if (step.agent !== undefined && typeof step.agent !== "string") {
146
+ errors.push(`${path}.agent must be a string when provided`);
147
+ }
148
+ if (step.runInMain !== undefined && typeof step.runInMain !== "boolean") {
149
+ errors.push(`${path}.runInMain must be a boolean when provided`);
150
+ }
151
+ if (step.skipIf !== undefined && typeof step.skipIf !== "function") {
152
+ errors.push(`${path}.skipIf must be a function when provided`);
153
+ }
154
+ if (step.onFail !== undefined) {
155
+ validateOnFailPolicy(step.onFail, `${path}.onFail`, stepIds, errors);
156
+ }
157
+
158
+ if (step.checks !== undefined) {
159
+ if (!Array.isArray(step.checks)) {
160
+ errors.push(`${path}.checks must be an array when provided`);
161
+ } else {
162
+ step.checks.forEach((check, checkIndex) =>
163
+ validateCheck(check, `${path}.checks[${checkIndex}]`, stepIds, errors),
164
+ );
165
+ }
166
+ }
167
+ }
168
+
169
+ function validateRetryModelSelections(selections: unknown, path: string, errors: string[]): void {
170
+ if (!Array.isArray(selections)) {
171
+ errors.push(`${path} must be an array when provided`);
172
+ return;
173
+ }
174
+
175
+ const seenRetries = new Set<number>();
176
+ const duplicateRetries = new Set<number>();
177
+ selections.forEach((selection, index) => {
178
+ const selectionPath = `${path}[${index}]`;
179
+ if (!isRecord(selection)) {
180
+ errors.push(`${selectionPath} must be an object`);
181
+ return;
182
+ }
183
+
184
+ validateKnownKeys(selection, selectionPath, RETRY_MODEL_SELECTION_KEYS, errors);
185
+ if (!isNonNegativeInteger(selection.retry)) {
186
+ errors.push(`${selectionPath}.retry must be a non-negative integer`);
187
+ } else {
188
+ const retry = selection.retry as number;
189
+ if (seenRetries.has(retry)) duplicateRetries.add(retry);
190
+ seenRetries.add(retry);
191
+ }
192
+ if (selection.model !== undefined && (typeof selection.model !== "string" || selection.model.length === 0)) {
193
+ errors.push(`${selectionPath}.model must be a non-empty string when provided`);
194
+ }
195
+ if (selection.thinkingLevel !== undefined && !isThinkingLevel(selection.thinkingLevel)) {
196
+ errors.push(
197
+ `${selectionPath}.thinkingLevel must be one of "off", "minimal", "low", "medium", "high", or "xhigh" when provided`,
198
+ );
199
+ }
200
+ if (selection.model === undefined && selection.thinkingLevel === undefined) {
201
+ errors.push(`${selectionPath} must provide model or thinkingLevel`);
202
+ }
203
+ });
204
+
205
+ for (const retry of duplicateRetries) errors.push(`${path} duplicate retry value ${retry}`);
206
+ }
207
+
208
+ function validateCheck(check: unknown, path: string, stepIds: Set<string>, errors: string[]): void {
209
+ if (!isRecord(check)) {
210
+ errors.push(`${path} must be an object`);
211
+ return;
212
+ }
213
+
214
+ if (check.id !== undefined && (typeof check.id !== "string" || check.id.length === 0)) {
215
+ errors.push(`${path}.id must be a non-empty string when provided`);
216
+ }
217
+ if (check.name !== undefined && typeof check.name !== "string") {
218
+ errors.push(`${path}.name must be a string when provided`);
219
+ }
220
+ if (check.onFail !== undefined) {
221
+ validateOnFailPolicy(check.onFail, `${path}.onFail`, stepIds, errors);
222
+ }
223
+
224
+ if (check.type === "deterministic") {
225
+ validateKnownKeys(check, path, DETERMINISTIC_CHECK_KEYS, errors);
226
+ validateDeterministicCheck(check as Record<string, unknown>, path, errors);
227
+ return;
228
+ }
229
+ if (check.type === "agent") {
230
+ validateKnownKeys(check, path, AGENT_CHECK_KEYS, errors);
231
+ validateAgentCheck(check as Record<string, unknown>, path, errors);
232
+ return;
233
+ }
234
+ validateKnownKeys(check, path, CHECK_KEYS, errors);
235
+ errors.push(`${path}.type must be "deterministic" or "agent"`);
236
+ }
237
+
238
+ function validateDeterministicCheck(check: Record<string, unknown>, path: string, errors: string[]): void {
239
+ const typed = check as unknown as DeterministicCheck;
240
+ if (!isTemplatable(typed.command)) {
241
+ errors.push(`${path}.command must be a string or function`);
242
+ }
243
+ if (check.cwd !== undefined && typeof check.cwd !== "string") {
244
+ errors.push(`${path}.cwd must be a string when provided`);
245
+ }
246
+ if (check.timeoutMs !== undefined && !isPositiveInteger(check.timeoutMs)) {
247
+ errors.push(`${path}.timeoutMs must be a positive integer when provided`);
248
+ }
249
+ }
250
+
251
+ function validateAgentCheck(check: Record<string, unknown>, path: string, errors: string[]): void {
252
+ const typed = check as unknown as AgentCheck;
253
+ if (!isTemplatable(typed.prompt)) {
254
+ errors.push(`${path}.prompt must be a string or function`);
255
+ }
256
+ if (check.agent !== undefined && typeof check.agent !== "string") {
257
+ errors.push(`${path}.agent must be a string when provided`);
258
+ }
259
+ }
260
+
261
+ function validateDelegation(delegation: unknown, path: string, errors: string[]): void {
262
+ if (delegation === "auto" || delegation === "none") return;
263
+ if (!isRecord(delegation)) {
264
+ errors.push(`${path} must be "auto", "none", { skill: string }, or { subagent: "cmux" | "herdr" }`);
265
+ return;
266
+ }
267
+ if ("subagent" in delegation) {
268
+ if (delegation.subagent !== "cmux" && delegation.subagent !== "herdr") {
269
+ errors.push(`${path}.subagent must be "cmux" or "herdr"`);
270
+ }
271
+ return;
272
+ }
273
+ if (typeof delegation.skill !== "string" || delegation.skill.length === 0) {
274
+ errors.push(`${path}.skill must be a non-empty string`);
275
+ }
276
+ }
277
+
278
+ function validateOnFailPolicy(
279
+ policy: unknown,
280
+ path: string,
281
+ stepIds: Set<string> | undefined,
282
+ errors: string[],
283
+ ): void {
284
+ if (policy === "stop" || policy === "continue") return;
285
+ if (!isRecord(policy)) {
286
+ errors.push(`${path} must be "stop", "continue", or a goto object`);
287
+ return;
288
+ }
289
+
290
+ validateKnownKeys(policy, path, ON_FAIL_KEYS, errors);
291
+ const goto = policy.goto;
292
+ if (typeof goto !== "string" || goto.length === 0) {
293
+ errors.push(`${path}.goto must be a non-empty string`);
294
+ } else if (stepIds && !stepIds.has(goto)) {
295
+ errors.push(`${path}.goto target "${goto}" does not exist`);
296
+ }
297
+ if (policy.maxLoops !== undefined && !isNonNegativeInteger(policy.maxLoops)) {
298
+ errors.push(`${path}.maxLoops must be a non-negative integer when provided`);
299
+ }
300
+ if (
301
+ policy.onExhausted !== undefined &&
302
+ policy.onExhausted !== "stop" &&
303
+ policy.onExhausted !== "continue"
304
+ ) {
305
+ errors.push(`${path}.onExhausted must be "stop" or "continue" when provided`);
306
+ }
307
+ if (policy.feedback !== undefined && typeof policy.feedback !== "boolean") {
308
+ errors.push(`${path}.feedback must be a boolean when provided`);
309
+ }
310
+ }
311
+
312
+ function validateKnownKeys(record: Record<string, unknown>, path: string, allowed: Set<string>, errors: string[]): void {
313
+ for (const key of Object.keys(record)) {
314
+ if (!allowed.has(key)) errors.push(`${path}.${key} is not recognized`);
315
+ }
316
+ }
317
+
318
+ function isRecord(value: unknown): value is Record<string, unknown> {
319
+ return typeof value === "object" && value !== null && !Array.isArray(value);
320
+ }
321
+
322
+ function isTemplatable(value: unknown): value is Templatable {
323
+ return typeof value === "string" || typeof value === "function";
324
+ }
325
+
326
+ function isThinkingLevel(value: unknown): value is WorkflowThinkingLevel {
327
+ return typeof value === "string" && THINKING_LEVELS.has(value as WorkflowThinkingLevel);
328
+ }
329
+
330
+ function isPositiveInteger(value: unknown): boolean {
331
+ return Number.isInteger(value) && (value as number) > 0;
332
+ }
333
+
334
+ function isNonNegativeInteger(value: unknown): boolean {
335
+ return Number.isInteger(value) && (value as number) >= 0;
336
+ }
337
+
338
+ export function getWorkflowNameCandidate(value: unknown, fallback: string): string {
339
+ return isRecord(value) && typeof value.name === "string" && value.name.length > 0 ? value.name : fallback;
340
+ }