@united-workforce/cli 0.2.1-rc.9 → 0.4.0

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.
Files changed (219) hide show
  1. package/README.md +15 -8
  2. package/dist/__tests__/adapter-json-roundtrip.test.js +1 -1
  3. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  4. package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
  5. package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
  6. package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
  7. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
  8. package/dist/__tests__/build-step-entry.test.d.ts +2 -0
  9. package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
  10. package/dist/__tests__/build-step-entry.test.js +173 -0
  11. package/dist/__tests__/build-step-entry.test.js.map +1 -0
  12. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
  13. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
  14. package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
  15. package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
  16. package/dist/__tests__/config.test.js +26 -302
  17. package/dist/__tests__/config.test.js.map +1 -1
  18. package/dist/__tests__/current-role.test.js +7 -6
  19. package/dist/__tests__/current-role.test.js.map +1 -1
  20. package/dist/__tests__/e2e-mock-agent.test.js +20 -23
  21. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  22. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
  23. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
  24. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
  25. package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
  26. package/dist/__tests__/moderator-evaluate.test.js +9 -50
  27. package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
  28. package/dist/__tests__/pid-recycling.test.d.ts +2 -0
  29. package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
  30. package/dist/__tests__/pid-recycling.test.js +271 -0
  31. package/dist/__tests__/pid-recycling.test.js.map +1 -0
  32. package/dist/__tests__/prompt.test.js +321 -0
  33. package/dist/__tests__/prompt.test.js.map +1 -1
  34. package/dist/__tests__/resolve-head-hash.test.js +4 -4
  35. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  36. package/dist/__tests__/setup-agent-discovery.test.js +21 -30
  37. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  38. package/dist/__tests__/setup-complexity.test.js +2 -168
  39. package/dist/__tests__/setup-complexity.test.js.map +1 -1
  40. package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
  41. package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
  42. package/dist/__tests__/setup-no-llm.test.js +52 -0
  43. package/dist/__tests__/setup-no-llm.test.js.map +1 -0
  44. package/dist/__tests__/solve-issue-tea-worktree.test.js +24 -27
  45. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  46. package/dist/__tests__/step-ask.test.d.ts +2 -0
  47. package/dist/__tests__/step-ask.test.d.ts.map +1 -0
  48. package/dist/__tests__/step-ask.test.js +499 -0
  49. package/dist/__tests__/step-ask.test.js.map +1 -0
  50. package/dist/__tests__/step-show-json.test.js +1 -0
  51. package/dist/__tests__/step-show-json.test.js.map +1 -1
  52. package/dist/__tests__/step-timing.test.js +2 -0
  53. package/dist/__tests__/step-timing.test.js.map +1 -1
  54. package/dist/__tests__/store-global-cas.test.js +2 -2
  55. package/dist/__tests__/store-global-cas.test.js.map +1 -1
  56. package/dist/__tests__/store-unified-threads.test.js +9 -9
  57. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  58. package/dist/__tests__/thread-cancel-status.test.js +6 -6
  59. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  60. package/dist/__tests__/thread-list-filters.test.js +344 -9
  61. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  62. package/dist/__tests__/thread-poke.test.d.ts +2 -0
  63. package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
  64. package/dist/__tests__/thread-poke.test.js +412 -0
  65. package/dist/__tests__/thread-poke.test.js.map +1 -0
  66. package/dist/__tests__/thread-resume.test.js +10 -14
  67. package/dist/__tests__/thread-resume.test.js.map +1 -1
  68. package/dist/__tests__/thread-show-status.test.js +17 -28
  69. package/dist/__tests__/thread-show-status.test.js.map +1 -1
  70. package/dist/__tests__/thread-suspend-step.test.js +8 -14
  71. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  72. package/dist/__tests__/thread-suspended-display.test.js +10 -22
  73. package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
  74. package/dist/__tests__/thread.test.js +4 -4
  75. package/dist/__tests__/thread.test.js.map +1 -1
  76. package/dist/__tests__/validate-semantic.test.js +49 -21
  77. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  78. package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
  79. package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
  80. package/dist/__tests__/workflow-list-recursive.test.js +283 -0
  81. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
  82. package/dist/__tests__/workflow-resolution.test.js +36 -21
  83. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  84. package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
  85. package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
  86. package/dist/__tests__/workflow-show-resolution.test.js +210 -0
  87. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
  88. package/dist/__tests__/workflow-validate.test.d.ts +2 -0
  89. package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
  90. package/dist/__tests__/workflow-validate.test.js +687 -0
  91. package/dist/__tests__/workflow-validate.test.js.map +1 -0
  92. package/dist/background/background.d.ts +22 -1
  93. package/dist/background/background.d.ts.map +1 -1
  94. package/dist/background/background.js +83 -6
  95. package/dist/background/background.js.map +1 -1
  96. package/dist/background/index.d.ts +1 -1
  97. package/dist/background/index.d.ts.map +1 -1
  98. package/dist/background/index.js +1 -1
  99. package/dist/background/index.js.map +1 -1
  100. package/dist/background/types.d.ts +1 -0
  101. package/dist/background/types.d.ts.map +1 -1
  102. package/dist/cli.js +66 -31
  103. package/dist/cli.js.map +1 -1
  104. package/dist/commands/config.d.ts +3 -1
  105. package/dist/commands/config.d.ts.map +1 -1
  106. package/dist/commands/config.js +7 -33
  107. package/dist/commands/config.js.map +1 -1
  108. package/dist/commands/prompt.d.ts.map +1 -1
  109. package/dist/commands/prompt.js +15 -2
  110. package/dist/commands/prompt.js.map +1 -1
  111. package/dist/commands/setup.d.ts +7 -39
  112. package/dist/commands/setup.d.ts.map +1 -1
  113. package/dist/commands/setup.js +27 -302
  114. package/dist/commands/setup.js.map +1 -1
  115. package/dist/commands/step.d.ts +44 -1
  116. package/dist/commands/step.d.ts.map +1 -1
  117. package/dist/commands/step.js +255 -11
  118. package/dist/commands/step.js.map +1 -1
  119. package/dist/commands/thread.d.ts +16 -3
  120. package/dist/commands/thread.d.ts.map +1 -1
  121. package/dist/commands/thread.js +379 -140
  122. package/dist/commands/thread.js.map +1 -1
  123. package/dist/commands/workflow.d.ts +9 -1
  124. package/dist/commands/workflow.d.ts.map +1 -1
  125. package/dist/commands/workflow.js +130 -6
  126. package/dist/commands/workflow.js.map +1 -1
  127. package/dist/moderator/__tests__/evaluate.test.js +31 -17
  128. package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
  129. package/dist/moderator/evaluate.d.ts.map +1 -1
  130. package/dist/moderator/evaluate.js +4 -16
  131. package/dist/moderator/evaluate.js.map +1 -1
  132. package/dist/moderator/index.d.ts +1 -2
  133. package/dist/moderator/index.d.ts.map +1 -1
  134. package/dist/moderator/index.js +0 -1
  135. package/dist/moderator/index.js.map +1 -1
  136. package/dist/moderator/types.d.ts +6 -10
  137. package/dist/moderator/types.d.ts.map +1 -1
  138. package/dist/moderator/types.js +1 -3
  139. package/dist/moderator/types.js.map +1 -1
  140. package/dist/schemas.d.ts +2 -0
  141. package/dist/schemas.d.ts.map +1 -1
  142. package/dist/schemas.js +5 -3
  143. package/dist/schemas.js.map +1 -1
  144. package/dist/store.d.ts +28 -9
  145. package/dist/store.d.ts.map +1 -1
  146. package/dist/store.js +75 -16
  147. package/dist/store.js.map +1 -1
  148. package/dist/validate-semantic.d.ts.map +1 -1
  149. package/dist/validate-semantic.js +83 -66
  150. package/dist/validate-semantic.js.map +1 -1
  151. package/dist/validate.d.ts +6 -0
  152. package/dist/validate.d.ts.map +1 -1
  153. package/dist/validate.js +24 -0
  154. package/dist/validate.js.map +1 -1
  155. package/package.json +8 -10
  156. package/src/__tests__/adapter-json-roundtrip.test.ts +1 -1
  157. package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
  158. package/src/__tests__/build-step-entry.test.ts +203 -0
  159. package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
  160. package/src/__tests__/config.test.ts +33 -321
  161. package/src/__tests__/current-role.test.ts +7 -6
  162. package/src/__tests__/e2e-mock-agent.test.ts +20 -23
  163. package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
  164. package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
  165. package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
  166. package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
  167. package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
  168. package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
  169. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
  170. package/src/__tests__/moderator-evaluate.test.ts +9 -52
  171. package/src/__tests__/pid-recycling.test.ts +328 -0
  172. package/src/__tests__/prompt.test.ts +397 -0
  173. package/src/__tests__/resolve-head-hash.test.ts +4 -4
  174. package/src/__tests__/setup-agent-discovery.test.ts +26 -51
  175. package/src/__tests__/setup-complexity.test.ts +1 -203
  176. package/src/__tests__/setup-no-llm.test.ts +68 -0
  177. package/src/__tests__/solve-issue-tea-worktree.test.ts +24 -30
  178. package/src/__tests__/step-ask.test.ts +670 -0
  179. package/src/__tests__/step-show-json.test.ts +1 -0
  180. package/src/__tests__/step-timing.test.ts +2 -0
  181. package/src/__tests__/store-global-cas.test.ts +2 -2
  182. package/src/__tests__/store-unified-threads.test.ts +9 -9
  183. package/src/__tests__/thread-cancel-status.test.ts +6 -6
  184. package/src/__tests__/thread-list-filters.test.ts +434 -8
  185. package/src/__tests__/thread-poke.test.ts +545 -0
  186. package/src/__tests__/thread-resume.test.ts +10 -14
  187. package/src/__tests__/thread-show-status.test.ts +17 -29
  188. package/src/__tests__/thread-suspend-step.test.ts +8 -14
  189. package/src/__tests__/thread-suspended-display.test.ts +10 -22
  190. package/src/__tests__/thread.test.ts +4 -4
  191. package/src/__tests__/validate-semantic.test.ts +59 -31
  192. package/src/__tests__/workflow-list-recursive.test.ts +370 -0
  193. package/src/__tests__/workflow-resolution.test.ts +39 -21
  194. package/src/__tests__/workflow-show-resolution.test.ts +285 -0
  195. package/src/__tests__/workflow-validate.test.ts +806 -0
  196. package/src/background/background.ts +88 -6
  197. package/src/background/index.ts +2 -0
  198. package/src/background/types.ts +1 -0
  199. package/src/cli.ts +97 -47
  200. package/src/commands/config.ts +7 -35
  201. package/src/commands/prompt.ts +15 -2
  202. package/src/commands/setup.ts +29 -357
  203. package/src/commands/step.ts +339 -12
  204. package/src/commands/thread.ts +463 -169
  205. package/src/commands/workflow.ts +159 -4
  206. package/src/moderator/__tests__/evaluate.test.ts +34 -17
  207. package/src/moderator/evaluate.ts +5 -17
  208. package/src/moderator/index.ts +1 -6
  209. package/src/moderator/types.ts +6 -14
  210. package/src/schemas.ts +13 -3
  211. package/src/store.ts +86 -20
  212. package/src/validate-semantic.ts +109 -78
  213. package/src/validate.ts +27 -0
  214. package/dist/__tests__/setup-validate.test.d.ts +0 -2
  215. package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
  216. package/dist/__tests__/setup-validate.test.js +0 -108
  217. package/dist/__tests__/setup-validate.test.js.map +0 -1
  218. package/src/__tests__/setup-validate.test.ts +0 -148
  219. /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
@@ -1,21 +1,11 @@
1
1
  import type { WorkflowPayload } from "@united-workforce/protocol";
2
+ import { Liquid } from "liquidjs";
2
3
 
3
4
  type SchemaObj = Record<string, unknown>;
4
5
 
5
6
  const RESERVED_NAMES = new Set(["$START", "$END", "$SUSPEND"]);
6
- const PSEUDO_TARGETS = new Set(["$END", "$SUSPEND"]);
7
-
8
- /** Extract mustache variable names from a prompt string. */
9
- function extractMustacheVars(prompt: string): string[] {
10
- const vars: string[] = [];
11
- const re = /\{\{\{?([^}]+)\}\}\}?/g;
12
- let m: RegExpExecArray | null = re.exec(prompt);
13
- while (m !== null) {
14
- vars.push(m[1]);
15
- m = re.exec(prompt);
16
- }
17
- return vars;
18
- }
7
+ const PSEUDO_TARGETS = new Set(["$END"]);
8
+ const SUSPEND_TARGET = "$SUSPEND";
19
9
 
20
10
  /** Check if a frontmatter schema is a oneOf (multi-exit) type. */
21
11
  function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
@@ -42,13 +32,6 @@ function getConstStatuses(fm: SchemaObj): string[] {
42
32
  return [];
43
33
  }
44
34
 
45
- /** Get property names from a schema object. */
46
- function getPropertyNames(schema: SchemaObj): Set<string> {
47
- const props = schema.properties;
48
- if (typeof props !== "object" || props === null) return new Set();
49
- return new Set(Object.keys(props as Record<string, unknown>));
50
- }
51
-
52
35
  /** Extract $status const values from oneOf variants. */
53
36
  function getOneOfStatuses(variants: SchemaObj[]): string[] {
54
37
  const statuses: string[] = [];
@@ -64,6 +47,83 @@ function getOneOfStatuses(variants: SchemaObj[]): string[] {
64
47
  return statuses;
65
48
  }
66
49
 
50
+ /** Generate mock data from schema property names for template rendering. */
51
+ function generateMockData(schema: SchemaObj): Record<string, string> {
52
+ const mock: Record<string, string> = {};
53
+ const props = schema.properties as Record<string, SchemaObj> | undefined;
54
+ if (!props) return mock;
55
+ for (const key of Object.keys(props)) {
56
+ mock[key] = `mock_${key}`;
57
+ }
58
+ return mock;
59
+ }
60
+
61
+ /**
62
+ * Pre-process a template to replace `$`-prefixed variables (like `$status`)
63
+ * which are invalid in LiquidJS syntax but always valid at runtime.
64
+ * Replaces `{{ $varName }}` with a literal placeholder so the strict render
65
+ * does not reject them.
66
+ */
67
+ function sanitizeReservedVars(template: string): string {
68
+ return template.replace(/\{\{\s*\$\w+\s*\}\}/g, "RESERVED");
69
+ }
70
+
71
+ /** Extract variable name from a LiquidJS UndefinedVariableError message. */
72
+ function extractVarName(err: unknown): string {
73
+ const msg = String(err);
74
+ const match = msg.match(/undefined variable: ([^,\s]+)/);
75
+ return match ? match[1] : "unknown";
76
+ }
77
+
78
+ /** Validate edge templates using LiquidJS strict-render for a multi-exit role. */
79
+ function validateMultiExitTemplates(
80
+ roleName: string,
81
+ graphEntry: Record<string, { role: string; prompt: string }>,
82
+ variants: SchemaObj[],
83
+ errors: string[],
84
+ ): void {
85
+ const strictEngine = new Liquid({ strictVariables: true });
86
+
87
+ for (const [status, target] of Object.entries(graphEntry)) {
88
+ const variant = variants.find((v) => {
89
+ const props = v.properties as Record<string, SchemaObj> | undefined;
90
+ return props?.$status?.const === status;
91
+ });
92
+ if (!variant) continue;
93
+ const mockData = generateMockData(variant);
94
+ try {
95
+ strictEngine.parseAndRenderSync(sanitizeReservedVars(target.prompt), mockData);
96
+ } catch (err) {
97
+ const varName = extractVarName(err);
98
+ errors.push(
99
+ `template variable "${varName}" not found in role "${roleName}" variant "${status}"`,
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Validate edge templates using LiquidJS strict-render for a flat schema. */
106
+ function validateFlatTemplates(
107
+ roleName: string,
108
+ graphEntry: Record<string, { role: string; prompt: string }>,
109
+ fm: SchemaObj,
110
+ errors: string[],
111
+ ): void {
112
+ const strictEngine = new Liquid({ strictVariables: true });
113
+ const mockData = generateMockData(fm);
114
+
115
+ for (const [status, target] of Object.entries(graphEntry)) {
116
+ try {
117
+ strictEngine.parseAndRenderSync(sanitizeReservedVars(target.prompt), mockData);
118
+ } catch (err) {
119
+ const varName = extractVarName(err);
120
+ errors.push(
121
+ `template variable "${varName}" in graph[${roleName}][${status}] not found in role "${roleName}" frontmatter`,
122
+ );
123
+ }
124
+ }
125
+ }
126
+
67
127
  /** Check reserved names and role/graph reference integrity. */
68
128
  function checkRoleReferences(payload: WorkflowPayload, errors: string[]): void {
69
129
  const roleNames = new Set(Object.keys(payload.roles));
@@ -89,6 +149,27 @@ function checkRoleReferences(payload: WorkflowPayload, errors: string[]): void {
89
149
  }
90
150
  }
91
151
 
152
+ /** Validate each graph edge's target role, including the removed $SUSPEND target. */
153
+ function checkEdgeTargets(
154
+ payload: WorkflowPayload,
155
+ roleNames: Set<string>,
156
+ errors: string[],
157
+ ): void {
158
+ for (const [node, statusMap] of Object.entries(payload.graph)) {
159
+ for (const [status, target] of Object.entries(statusMap)) {
160
+ if (target.role === SUSPEND_TARGET) {
161
+ errors.push(
162
+ `edge ${node}→${status}: "${SUSPEND_TARGET}" is no longer a valid graph target. Emit $status: "${SUSPEND_TARGET}" from the "${node}" role output instead.`,
163
+ );
164
+ continue;
165
+ }
166
+ if (!PSEUDO_TARGETS.has(target.role) && !roleNames.has(target.role)) {
167
+ errors.push(`edge ${node}→${status}: unknown target role "${target.role}"`);
168
+ }
169
+ }
170
+ }
171
+ }
172
+
92
173
  /** Check $START/$END constraints, edge targets, and reachability. */
93
174
  function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void {
94
175
  const roleNames = new Set(Object.keys(payload.roles));
@@ -107,17 +188,13 @@ function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void {
107
188
  errors.push("$END must not have outgoing edges");
108
189
  }
109
190
 
110
- if (graphNodes.has("$SUSPEND")) {
111
- errors.push("$SUSPEND must not have outgoing edges");
191
+ if (graphNodes.has(SUSPEND_TARGET)) {
192
+ errors.push(
193
+ `"${SUSPEND_TARGET}" is no longer a valid graph node — it is now an engine-level reserved $status. Emit $status: "${SUSPEND_TARGET}" from a role output instead.`,
194
+ );
112
195
  }
113
196
 
114
- for (const [node, statusMap] of Object.entries(payload.graph)) {
115
- for (const [status, target] of Object.entries(statusMap)) {
116
- if (!PSEUDO_TARGETS.has(target.role) && !roleNames.has(target.role)) {
117
- errors.push(`edge ${node}→${status}: unknown target role "${target.role}"`);
118
- }
119
- }
120
- }
197
+ checkEdgeTargets(payload, roleNames, errors);
121
198
 
122
199
  checkReachability(roleNames, collectReachableRoles(payload.graph), errors);
123
200
  }
@@ -207,31 +284,7 @@ function checkStatusEdges(
207
284
  }
208
285
  }
209
286
 
210
- /** Check mustache variables for multi-exit role. */
211
- function checkMultiExitMustache(
212
- roleName: string,
213
- graphEntry: Record<string, { role: string; prompt: string }>,
214
- variants: SchemaObj[],
215
- errors: string[],
216
- ): void {
217
- for (const [status, target] of Object.entries(graphEntry)) {
218
- const vars = extractMustacheVars(target.prompt);
219
- const variant = variants.find((v) => {
220
- const props = v.properties as Record<string, SchemaObj> | undefined;
221
- return props?.$status?.const === status;
222
- });
223
- if (!variant) continue;
224
- const propNames = getPropertyNames(variant);
225
- for (const v of vars) {
226
- if (v === "$status") continue;
227
- if (!propNames.has(v)) {
228
- errors.push(`prompt variable "${v}" not found in role "${roleName}" variant "${status}"`);
229
- }
230
- }
231
- }
232
- }
233
-
234
- /** Check status-edge consistency and mustache for each role. */
287
+ /** Check status-edge consistency and template vars for each role. */
235
288
  function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void {
236
289
  for (const [roleName, role] of Object.entries(payload.roles)) {
237
290
  if (RESERVED_NAMES.has(roleName)) continue;
@@ -247,12 +300,11 @@ function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void
247
300
 
248
301
  checkOneOfDiscriminant(roleName, variants, statuses, errors);
249
302
  checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
250
- checkMultiExitMustache(roleName, graphEntry, variants, errors);
303
+ validateMultiExitTemplates(roleName, graphEntry, variants, errors);
251
304
  } else if (hasStatusConst(fm)) {
252
305
  const statuses = getConstStatuses(fm as SchemaObj);
253
306
  checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
254
- // For const-based flat schemas, mustache vars come from the flat properties
255
- checkFlatMustache(roleName, graphEntry, fm as SchemaObj, errors);
307
+ validateFlatTemplates(roleName, graphEntry, fm as SchemaObj, errors);
256
308
  } else {
257
309
  errors.push(
258
310
  `role "${roleName}" must define "$status" as const (or oneOf with const) in frontmatter`,
@@ -261,27 +313,6 @@ function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void
261
313
  }
262
314
  }
263
315
 
264
- /** Check mustache vars in all edge prompts against flat schema properties. */
265
- function checkFlatMustache(
266
- roleName: string,
267
- graphEntry: Record<string, { role: string; prompt: string }>,
268
- fm: SchemaObj,
269
- errors: string[],
270
- ): void {
271
- const propNames = getPropertyNames(fm);
272
- for (const [status, target] of Object.entries(graphEntry)) {
273
- const vars = extractMustacheVars(target.prompt);
274
- for (const v of vars) {
275
- if (v === "$status") continue;
276
- if (!propNames.has(v)) {
277
- errors.push(
278
- `prompt variable "${v}" in graph[${roleName}][${status}] not found in role "${roleName}" frontmatter`,
279
- );
280
- }
281
- }
282
- }
283
- }
284
-
285
316
  /**
286
317
  * Validate a parsed WorkflowPayload for semantic correctness.
287
318
  * Returns an array of error messages. Empty array = valid.
package/src/validate.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { basename, dirname } from "node:path";
2
2
  import type { CasRef, WorkflowPayload } from "@united-workforce/protocol";
3
+ import { CURRENT_WORKFLOW_VERSION } from "@united-workforce/protocol";
3
4
 
4
5
  const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
5
6
 
@@ -113,12 +114,26 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
113
114
  if (typeof raw.name !== "string" || typeof raw.description !== "string") {
114
115
  return null;
115
116
  }
117
+ // version is optional in legacy YAML — falls back to CURRENT_WORKFLOW_VERSION.
118
+ // When present, it MUST be an integer (booleans, strings, floats are rejected).
119
+ if (raw.version !== undefined) {
120
+ if (
121
+ typeof raw.version !== "number" ||
122
+ !Number.isInteger(raw.version) ||
123
+ typeof raw.version === "boolean"
124
+ ) {
125
+ return null;
126
+ }
127
+ }
116
128
  if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
117
129
  return null;
118
130
  }
119
131
 
120
132
  // Normalize location field: undefined → null
121
133
  const normalized = { ...raw } as WorkflowPayload;
134
+ if (normalized.version === undefined || normalized.version === null) {
135
+ normalized.version = CURRENT_WORKFLOW_VERSION;
136
+ }
122
137
  for (const roleName of Object.keys(normalized.graph)) {
123
138
  const statusMap = normalized.graph[roleName];
124
139
  if (statusMap !== undefined) {
@@ -135,3 +150,15 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
135
150
 
136
151
  return normalized;
137
152
  }
153
+
154
+ /**
155
+ * Returns true when the parsed YAML document had no top-level `version` field.
156
+ * Used by `uwf workflow add` to emit a deprecation warning while still
157
+ * accepting legacy workflow YAML.
158
+ */
159
+ export function isMissingVersion(raw: unknown): boolean {
160
+ if (!isRecord(raw)) {
161
+ return false;
162
+ }
163
+ return raw.version === undefined;
164
+ }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=setup-validate.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"setup-validate.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/setup-validate.test.ts"],"names":[],"mappings":""}
@@ -1,108 +0,0 @@
1
- import { mkdtemp, rm } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
5
- import { cmdSetup, validateModel } from "../commands/setup.js";
6
- describe("validateModel", () => {
7
- const BASE_URL = "https://api.example.com/v1";
8
- const API_KEY = "sk-test-key";
9
- const MODEL = "test-model";
10
- afterEach(() => {
11
- vi.restoreAllMocks();
12
- });
13
- test("success path — returns ok on 200", async () => {
14
- const mockFetch = vi
15
- .spyOn(globalThis, "fetch")
16
- .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
17
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
18
- expect(result).toEqual({ ok: true, value: undefined });
19
- expect(mockFetch).toHaveBeenCalledTimes(1);
20
- const [url, opts] = mockFetch.mock.calls[0];
21
- expect(url).toBe(`${BASE_URL}/chat/completions`);
22
- expect(opts.headers).toEqual(expect.objectContaining({ Authorization: `Bearer ${API_KEY}` }));
23
- const body = JSON.parse(opts.body);
24
- expect(body).toEqual({
25
- model: MODEL,
26
- messages: [{ role: "user", content: "hi" }],
27
- max_tokens: 1,
28
- });
29
- });
30
- test("HTTP 401 — returns error containing 401", async () => {
31
- vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }));
32
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
33
- expect(result.ok).toBe(false);
34
- if (!result.ok) {
35
- expect(result.error).toContain("401");
36
- }
37
- });
38
- test("HTTP 404 — returns error containing 404", async () => {
39
- vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404, statusText: "Not Found" }));
40
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
41
- expect(result.ok).toBe(false);
42
- if (!result.ok) {
43
- expect(result.error).toContain("404");
44
- }
45
- });
46
- test("network timeout — returns error mentioning timeout", async () => {
47
- const err = new DOMException("signal timed out", "AbortError");
48
- vi.spyOn(globalThis, "fetch").mockRejectedValue(err);
49
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
50
- expect(result.ok).toBe(false);
51
- if (!result.ok) {
52
- expect(result.error.toLowerCase()).toMatch(/timeout|timed out/);
53
- }
54
- });
55
- test("network error (DNS/connection) — returns error mentioning connectivity", async () => {
56
- vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
57
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
58
- expect(result.ok).toBe(false);
59
- if (!result.ok) {
60
- expect(result.error.toLowerCase()).toMatch(/connect|reach|network/);
61
- }
62
- });
63
- test("request body correctness", async () => {
64
- const mockFetch = vi
65
- .spyOn(globalThis, "fetch")
66
- .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
67
- await validateModel(BASE_URL, API_KEY, "my-special-model");
68
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
69
- expect(body).toEqual({
70
- model: "my-special-model",
71
- messages: [{ role: "user", content: "hi" }],
72
- max_tokens: 1,
73
- });
74
- });
75
- });
76
- describe("cmdSetup with validation", () => {
77
- let storageRoot;
78
- beforeEach(async () => {
79
- storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-validate-"));
80
- });
81
- afterEach(async () => {
82
- vi.restoreAllMocks();
83
- await rm(storageRoot, { recursive: true, force: true });
84
- });
85
- const setupArgs = () => ({
86
- provider: "testprovider",
87
- baseUrl: "https://api.test.com/v1",
88
- apiKey: "sk-test",
89
- model: "test-model",
90
- storageRoot,
91
- });
92
- test("includes validation result on success", async () => {
93
- vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
94
- const result = await cmdSetup(setupArgs());
95
- expect(result.validation).toEqual({ ok: true, value: undefined });
96
- // Config file should still be written
97
- expect(result.configPath).toBeTruthy();
98
- });
99
- test("includes validation failure — config still saved", async () => {
100
- vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }));
101
- const result = await cmdSetup(setupArgs());
102
- expect(result.validation).toBeDefined();
103
- expect(result.validation.ok).toBe(false);
104
- // Config file should still be written despite validation failure
105
- expect(result.configPath).toBeTruthy();
106
- });
107
- });
108
- //# sourceMappingURL=setup-validate.test.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"setup-validate.test.js","sourceRoot":"","sources":["../../src/__tests__/setup-validate.test.ts"],"names":[],"mappings":"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"}
@@ -1,148 +0,0 @@
1
- import { mkdtemp, rm } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
5
- import { cmdSetup, validateModel } from "../commands/setup.js";
6
-
7
- describe("validateModel", () => {
8
- const BASE_URL = "https://api.example.com/v1";
9
- const API_KEY = "sk-test-key";
10
- const MODEL = "test-model";
11
-
12
- afterEach(() => {
13
- vi.restoreAllMocks();
14
- });
15
-
16
- test("success path — returns ok on 200", async () => {
17
- const mockFetch = vi
18
- .spyOn(globalThis, "fetch")
19
- .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
20
-
21
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
22
-
23
- expect(result).toEqual({ ok: true, value: undefined });
24
- expect(mockFetch).toHaveBeenCalledTimes(1);
25
-
26
- const [url, opts] = mockFetch.mock.calls[0]!;
27
- expect(url).toBe(`${BASE_URL}/chat/completions`);
28
- expect((opts as RequestInit).headers).toEqual(
29
- expect.objectContaining({ Authorization: `Bearer ${API_KEY}` }),
30
- );
31
- const body = JSON.parse((opts as RequestInit).body as string);
32
- expect(body).toEqual({
33
- model: MODEL,
34
- messages: [{ role: "user", content: "hi" }],
35
- max_tokens: 1,
36
- });
37
- });
38
-
39
- test("HTTP 401 — returns error containing 401", async () => {
40
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
41
- new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
42
- );
43
-
44
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
45
-
46
- expect(result.ok).toBe(false);
47
- if (!result.ok) {
48
- expect(result.error).toContain("401");
49
- }
50
- });
51
-
52
- test("HTTP 404 — returns error containing 404", async () => {
53
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
54
- new Response("Not Found", { status: 404, statusText: "Not Found" }),
55
- );
56
-
57
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
58
-
59
- expect(result.ok).toBe(false);
60
- if (!result.ok) {
61
- expect(result.error).toContain("404");
62
- }
63
- });
64
-
65
- test("network timeout — returns error mentioning timeout", async () => {
66
- const err = new DOMException("signal timed out", "AbortError");
67
- vi.spyOn(globalThis, "fetch").mockRejectedValue(err);
68
-
69
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
70
-
71
- expect(result.ok).toBe(false);
72
- if (!result.ok) {
73
- expect(result.error.toLowerCase()).toMatch(/timeout|timed out/);
74
- }
75
- });
76
-
77
- test("network error (DNS/connection) — returns error mentioning connectivity", async () => {
78
- vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
79
-
80
- const result = await validateModel(BASE_URL, API_KEY, MODEL);
81
-
82
- expect(result.ok).toBe(false);
83
- if (!result.ok) {
84
- expect(result.error.toLowerCase()).toMatch(/connect|reach|network/);
85
- }
86
- });
87
-
88
- test("request body correctness", async () => {
89
- const mockFetch = vi
90
- .spyOn(globalThis, "fetch")
91
- .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
92
-
93
- await validateModel(BASE_URL, API_KEY, "my-special-model");
94
-
95
- const body = JSON.parse((mockFetch.mock.calls[0]![1] as RequestInit).body as string);
96
- expect(body).toEqual({
97
- model: "my-special-model",
98
- messages: [{ role: "user", content: "hi" }],
99
- max_tokens: 1,
100
- });
101
- });
102
- });
103
-
104
- describe("cmdSetup with validation", () => {
105
- let storageRoot: string;
106
-
107
- beforeEach(async () => {
108
- storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-validate-"));
109
- });
110
-
111
- afterEach(async () => {
112
- vi.restoreAllMocks();
113
- await rm(storageRoot, { recursive: true, force: true });
114
- });
115
-
116
- const setupArgs = () => ({
117
- provider: "testprovider",
118
- baseUrl: "https://api.test.com/v1",
119
- apiKey: "sk-test",
120
- model: "test-model",
121
- storageRoot,
122
- });
123
-
124
- test("includes validation result on success", async () => {
125
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
126
- new Response(JSON.stringify({}), { status: 200 }),
127
- );
128
-
129
- const result = await cmdSetup(setupArgs());
130
-
131
- expect(result.validation).toEqual({ ok: true, value: undefined });
132
- // Config file should still be written
133
- expect(result.configPath).toBeTruthy();
134
- });
135
-
136
- test("includes validation failure — config still saved", async () => {
137
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
138
- new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
139
- );
140
-
141
- const result = await cmdSetup(setupArgs());
142
-
143
- expect(result.validation).toBeDefined();
144
- expect((result.validation as { ok: boolean }).ok).toBe(false);
145
- // Config file should still be written despite validation failure
146
- expect(result.configPath).toBeTruthy();
147
- });
148
- });