@united-workforce/cli 0.5.0 → 0.6.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.
Files changed (54) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__tests__/config-text-renderer.test.d.ts +2 -0
  3. package/dist/__tests__/config-text-renderer.test.d.ts.map +1 -0
  4. package/dist/__tests__/config-text-renderer.test.js +137 -0
  5. package/dist/__tests__/config-text-renderer.test.js.map +1 -0
  6. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +1 -1
  7. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts +2 -0
  8. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts.map +1 -0
  9. package/dist/__tests__/thread-agent-failure-suspended.test.js +332 -0
  10. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -0
  11. package/dist/__tests__/thread-join.test.d.ts +2 -0
  12. package/dist/__tests__/thread-join.test.d.ts.map +1 -0
  13. package/dist/__tests__/thread-join.test.js +77 -0
  14. package/dist/__tests__/thread-join.test.js.map +1 -0
  15. package/dist/__tests__/thread-poke.test.js +4 -1
  16. package/dist/__tests__/thread-poke.test.js.map +1 -1
  17. package/dist/__tests__/workflow-paths.test.d.ts +2 -0
  18. package/dist/__tests__/workflow-paths.test.d.ts.map +1 -0
  19. package/dist/__tests__/workflow-paths.test.js +261 -0
  20. package/dist/__tests__/workflow-paths.test.js.map +1 -0
  21. package/dist/cli.js +18 -1
  22. package/dist/cli.js.map +1 -1
  23. package/dist/commands/config.d.ts +5 -0
  24. package/dist/commands/config.d.ts.map +1 -1
  25. package/dist/commands/config.js +69 -3
  26. package/dist/commands/config.js.map +1 -1
  27. package/dist/commands/thread.d.ts +12 -0
  28. package/dist/commands/thread.d.ts.map +1 -1
  29. package/dist/commands/thread.js +183 -8
  30. package/dist/commands/thread.js.map +1 -1
  31. package/dist/commands/workflow.d.ts +1 -1
  32. package/dist/commands/workflow.d.ts.map +1 -1
  33. package/dist/commands/workflow.js +24 -4
  34. package/dist/commands/workflow.js.map +1 -1
  35. package/dist/output-mappers.d.ts.map +1 -1
  36. package/dist/output-mappers.js +1 -1
  37. package/dist/output-mappers.js.map +1 -1
  38. package/dist/store.d.ts +11 -0
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/store.js +20 -1
  41. package/dist/store.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/__tests__/config-text-renderer.test.ts +156 -0
  44. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +1 -1
  45. package/src/__tests__/thread-agent-failure-suspended.test.ts +406 -0
  46. package/src/__tests__/thread-join.test.ts +103 -0
  47. package/src/__tests__/thread-poke.test.ts +4 -1
  48. package/src/__tests__/workflow-paths.test.ts +337 -0
  49. package/src/cli.ts +19 -0
  50. package/src/commands/config.ts +74 -3
  51. package/src/commands/thread.ts +233 -8
  52. package/src/commands/workflow.ts +29 -4
  53. package/src/output-mappers.ts +2 -1
  54. package/src/store.ts +25 -1
@@ -0,0 +1,337 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CasRef, WorkflowPayload } from "@united-workforce/protocol";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { stringify } from "yaml";
7
+ import { getConfigPath, loadWorkflowPaths } from "../commands/config.js";
8
+ import { cmdThreadStart } from "../commands/thread.js";
9
+ import { cmdWorkflowList } from "../commands/workflow.js";
10
+ import { discoverWorkflowPathsEntries, saveWorkflowRegistry, type UwfStore } from "../store.js";
11
+ import { makeUwfStore } from "./thread-test-helpers.js";
12
+
13
+ // ── helpers ───────────────────────────────────────────────────────────────────
14
+
15
+ function makeMinimalPayload(name: string, description: string): WorkflowPayload {
16
+ return {
17
+ version: 1,
18
+ name,
19
+ description,
20
+ roles: {
21
+ worker: {
22
+ description: "worker role",
23
+ goal: "do work",
24
+ capabilities: [],
25
+ procedure: "",
26
+ output: "",
27
+ frontmatter: {
28
+ type: "object",
29
+ properties: {
30
+ $status: { const: "done" },
31
+ },
32
+ required: ["$status"],
33
+ } as unknown as CasRef,
34
+ },
35
+ },
36
+ graph: {
37
+ $START: {
38
+ new: { role: "worker", prompt: "start working", location: null },
39
+ resume: { role: "worker", prompt: "resume working", location: null },
40
+ },
41
+ worker: { done: { role: "$END", prompt: "done", location: null } },
42
+ },
43
+ };
44
+ }
45
+
46
+ async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
47
+ const payload = makeMinimalPayload(
48
+ name,
49
+ version !== null ? `Test workflow (${version})` : "Test workflow",
50
+ );
51
+ return stringify(payload);
52
+ }
53
+
54
+ async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
55
+ const payload = makeMinimalPayload(name, "Test workflow");
56
+ return await uwf.store.cas.put(uwf.schemas.workflow, payload);
57
+ }
58
+
59
+ function writeConfigWithPaths(storageRoot: string, paths: string[]): void {
60
+ const { writeFileSync, mkdirSync, existsSync } = require("node:fs") as typeof import("node:fs");
61
+ const configPath = getConfigPath(storageRoot);
62
+ const dir = join(configPath, "..");
63
+ if (!existsSync(dir)) {
64
+ mkdirSync(dir, { recursive: true });
65
+ }
66
+ const { stringify: yamlStringify } = require("yaml") as typeof import("yaml");
67
+ writeFileSync(configPath, yamlStringify({ workflowPaths: paths }), "utf8");
68
+ }
69
+
70
+ // ── fixture ───────────────────────────────────────────────────────────────────
71
+
72
+ let tmpDir: string;
73
+ let storageRoot: string;
74
+ let projectRoot: string;
75
+ let savedOcasHome: string | undefined;
76
+
77
+ beforeEach(async () => {
78
+ savedOcasHome = process.env.OCAS_HOME;
79
+ tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-wfpaths-test-"));
80
+ storageRoot = join(tmpDir, "storage");
81
+ projectRoot = join(tmpDir, "project");
82
+ await mkdir(storageRoot, { recursive: true });
83
+ await mkdir(projectRoot, { recursive: true });
84
+ });
85
+
86
+ afterEach(async () => {
87
+ if (savedOcasHome === undefined) {
88
+ delete process.env.OCAS_HOME;
89
+ } else {
90
+ process.env.OCAS_HOME = savedOcasHome;
91
+ }
92
+ await rm(tmpDir, { recursive: true, force: true });
93
+ });
94
+
95
+ // ── discoverWorkflowPathsEntries ──────────────────────────────────────────────
96
+
97
+ describe("discoverWorkflowPathsEntries", () => {
98
+ test("should find workflows in specified directories", async () => {
99
+ const dir1 = join(tmpDir, "workflows1");
100
+ await mkdir(dir1, { recursive: true });
101
+ await writeFile(join(dir1, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
102
+ await writeFile(join(dir1, "review-pr.yaml"), await createWorkflowYaml("review-pr"));
103
+
104
+ const entries = await discoverWorkflowPathsEntries([dir1]);
105
+
106
+ expect(entries).toHaveLength(2);
107
+ const names = entries.map((e) => e.name).sort();
108
+ expect(names).toEqual(["review-pr", "solve-issue"]);
109
+ });
110
+
111
+ test("should handle multiple directories with first dir winning on collision", async () => {
112
+ const dir1 = join(tmpDir, "workflows1");
113
+ const dir2 = join(tmpDir, "workflows2");
114
+ await mkdir(dir1, { recursive: true });
115
+ await mkdir(dir2, { recursive: true });
116
+
117
+ await writeFile(join(dir1, "solve-issue.yaml"), await createWorkflowYaml("solve-issue", "v1"));
118
+ await writeFile(join(dir2, "solve-issue.yaml"), await createWorkflowYaml("solve-issue", "v2"));
119
+ await writeFile(join(dir2, "deploy.yaml"), await createWorkflowYaml("deploy"));
120
+
121
+ const entries = await discoverWorkflowPathsEntries([dir1, dir2]);
122
+
123
+ expect(entries).toHaveLength(2);
124
+ // solve-issue from dir1 wins
125
+ const solveIssue = entries.find((e) => e.name === "solve-issue");
126
+ expect(solveIssue?.filePath).toContain("workflows1");
127
+ // deploy only in dir2
128
+ expect(entries.find((e) => e.name === "deploy")).toBeDefined();
129
+ });
130
+
131
+ test("should gracefully skip non-existent directories", async () => {
132
+ const dir1 = join(tmpDir, "workflows1");
133
+ await mkdir(dir1, { recursive: true });
134
+ await writeFile(join(dir1, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
135
+
136
+ const entries = await discoverWorkflowPathsEntries([join(tmpDir, "nonexistent"), dir1]);
137
+
138
+ expect(entries).toHaveLength(1);
139
+ expect(entries[0].name).toBe("solve-issue");
140
+ });
141
+
142
+ test("should return empty array for empty dirs list", async () => {
143
+ const entries = await discoverWorkflowPathsEntries([]);
144
+ expect(entries).toHaveLength(0);
145
+ });
146
+
147
+ test("should find folder-based workflows", async () => {
148
+ const dir1 = join(tmpDir, "workflows1");
149
+ const folderWf = join(dir1, "solve-issue");
150
+ await mkdir(folderWf, { recursive: true });
151
+ await writeFile(join(folderWf, "index.yaml"), await createWorkflowYaml("solve-issue"));
152
+
153
+ const entries = await discoverWorkflowPathsEntries([dir1]);
154
+
155
+ expect(entries).toHaveLength(1);
156
+ expect(entries[0].name).toBe("solve-issue");
157
+ });
158
+ });
159
+
160
+ // ── loadWorkflowPaths ─────────────────────────────────────────────────────────
161
+
162
+ describe("loadWorkflowPaths", () => {
163
+ test("should return empty array when config does not exist", () => {
164
+ const paths = loadWorkflowPaths(join(tmpDir, "nonexistent-storage"));
165
+ expect(paths).toEqual([]);
166
+ });
167
+
168
+ test("should return empty array when workflowPaths key is missing", () => {
169
+ writeConfigWithPaths(storageRoot, []);
170
+ // Write config without workflowPaths
171
+ const { writeFileSync } = require("node:fs") as typeof import("node:fs");
172
+ writeFileSync(getConfigPath(storageRoot), "defaultAgent: hermes\n", "utf8");
173
+ const paths = loadWorkflowPaths(storageRoot);
174
+ expect(paths).toEqual([]);
175
+ });
176
+
177
+ test("should resolve paths from config", () => {
178
+ writeConfigWithPaths(storageRoot, ["/absolute/path", "./relative/path"]);
179
+ const paths = loadWorkflowPaths(storageRoot);
180
+ expect(paths[0]).toBe("/absolute/path");
181
+ // relative gets resolved to absolute
182
+ expect(paths[1]).toMatch(/\/relative\/path$/);
183
+ });
184
+ });
185
+
186
+ // ── Thread start resolution with workflowPaths ────────────────────────────────
187
+
188
+ describe("Strategy 3.5: workflowPaths Resolution", () => {
189
+ test("should resolve workflow from workflowPaths when not found locally", async () => {
190
+ await makeUwfStore(storageRoot);
191
+
192
+ // Create workflow in a workflowPaths dir
193
+ const globalDir = join(tmpDir, "global-workflows");
194
+ await mkdir(globalDir, { recursive: true });
195
+ await writeFile(join(globalDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
196
+
197
+ // Configure workflowPaths
198
+ writeConfigWithPaths(storageRoot, [globalDir]);
199
+
200
+ // No local .workflows/ — should fall through to workflowPaths
201
+ const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
202
+
203
+ expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
204
+ const uwf = await makeUwfStore(storageRoot);
205
+ const node = uwf.store.cas.get(result.workflow);
206
+ expect(node).not.toBeNull();
207
+ if (node !== null) {
208
+ expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
209
+ }
210
+ });
211
+
212
+ test("should prefer local .workflows/ over workflowPaths", async () => {
213
+ await makeUwfStore(storageRoot);
214
+
215
+ // Create workflow in workflowPaths dir
216
+ const globalDir = join(tmpDir, "global-workflows");
217
+ await mkdir(globalDir, { recursive: true });
218
+ await writeFile(
219
+ join(globalDir, "solve-issue.yaml"),
220
+ await createWorkflowYaml("solve-issue", "global"),
221
+ );
222
+
223
+ // Create workflow in local .workflows/
224
+ const localDir = join(projectRoot, ".workflows");
225
+ await mkdir(localDir, { recursive: true });
226
+ await writeFile(
227
+ join(localDir, "solve-issue.yaml"),
228
+ await createWorkflowYaml("solve-issue", "local"),
229
+ );
230
+
231
+ writeConfigWithPaths(storageRoot, [globalDir]);
232
+
233
+ const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
234
+
235
+ const uwf = await makeUwfStore(storageRoot);
236
+ const node = uwf.store.cas.get(result.workflow);
237
+ expect(node).not.toBeNull();
238
+ if (node !== null) {
239
+ // Should be the local version
240
+ expect((node.payload as WorkflowPayload).description).toBe("Test workflow (local)");
241
+ }
242
+ });
243
+
244
+ test("should prefer workflowPaths over global registry", async () => {
245
+ const uwf = await makeUwfStore(storageRoot);
246
+
247
+ // Register in global registry
248
+ const globalHash = await storeWorkflow(uwf, "solve-issue");
249
+ saveWorkflowRegistry(uwf.varStore, "solve-issue", globalHash);
250
+
251
+ // Create workflow in workflowPaths dir
252
+ const pathsDir = join(tmpDir, "paths-workflows");
253
+ await mkdir(pathsDir, { recursive: true });
254
+ await writeFile(
255
+ join(pathsDir, "solve-issue.yaml"),
256
+ await createWorkflowYaml("solve-issue", "from-paths"),
257
+ );
258
+
259
+ writeConfigWithPaths(storageRoot, [pathsDir]);
260
+
261
+ const isolatedRoot = join(tmpDir, "isolated");
262
+ await mkdir(isolatedRoot, { recursive: true });
263
+
264
+ const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", isolatedRoot);
265
+
266
+ const uwf2 = await makeUwfStore(storageRoot);
267
+ const node = uwf2.store.cas.get(result.workflow);
268
+ expect(node).not.toBeNull();
269
+ if (node !== null) {
270
+ expect((node.payload as WorkflowPayload).description).toBe("Test workflow (from-paths)");
271
+ }
272
+ });
273
+ });
274
+
275
+ // ── cmdWorkflowList with workflowPaths ────────────────────────────────────────
276
+
277
+ describe("cmdWorkflowList with workflowPaths", () => {
278
+ test("should include workflowPaths entries with correct origin", async () => {
279
+ await makeUwfStore(storageRoot);
280
+
281
+ // Create workflow in workflowPaths dir
282
+ const globalDir = join(tmpDir, "global-workflows");
283
+ await mkdir(globalDir, { recursive: true });
284
+ await writeFile(join(globalDir, "deploy.yaml"), await createWorkflowYaml("deploy"));
285
+
286
+ writeConfigWithPaths(storageRoot, [globalDir]);
287
+
288
+ const result = await cmdWorkflowList(storageRoot, projectRoot);
289
+
290
+ const deploy = result.find((e) => e.name === "deploy");
291
+ expect(deploy).toBeDefined();
292
+ expect(deploy?.origin).toBe("paths");
293
+ expect(deploy?.hash).toBe("(paths)");
294
+ });
295
+
296
+ test("should show local over paths when names collide", async () => {
297
+ await makeUwfStore(storageRoot);
298
+
299
+ // Create in both local and paths
300
+ const globalDir = join(tmpDir, "global-workflows");
301
+ await mkdir(globalDir, { recursive: true });
302
+ await writeFile(join(globalDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
303
+
304
+ const localDir = join(projectRoot, ".workflows");
305
+ await mkdir(localDir, { recursive: true });
306
+ await writeFile(join(localDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
307
+
308
+ writeConfigWithPaths(storageRoot, [globalDir]);
309
+
310
+ const result = await cmdWorkflowList(storageRoot, projectRoot);
311
+
312
+ const solveIssue = result.filter((e) => e.name === "solve-issue");
313
+ expect(solveIssue).toHaveLength(1);
314
+ expect(solveIssue[0].origin).toBe("local");
315
+ });
316
+
317
+ test("should show paths over registry when names collide", async () => {
318
+ const uwf = await makeUwfStore(storageRoot);
319
+
320
+ // Register globally
321
+ const hash = await storeWorkflow(uwf, "deploy");
322
+ saveWorkflowRegistry(uwf.varStore, "deploy", hash);
323
+
324
+ // Also in paths
325
+ const pathsDir = join(tmpDir, "paths-workflows");
326
+ await mkdir(pathsDir, { recursive: true });
327
+ await writeFile(join(pathsDir, "deploy.yaml"), await createWorkflowYaml("deploy"));
328
+
329
+ writeConfigWithPaths(storageRoot, [pathsDir]);
330
+
331
+ const result = await cmdWorkflowList(storageRoot, projectRoot);
332
+
333
+ const deploy = result.filter((e) => e.name === "deploy");
334
+ expect(deploy).toHaveLength(1);
335
+ expect(deploy[0].origin).toBe("paths");
336
+ });
337
+ });
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ import { cmdStepAsk, cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "
16
16
  import {
17
17
  cmdThreadCancel,
18
18
  cmdThreadExec,
19
+ cmdThreadJoin,
19
20
  cmdThreadList,
20
21
  cmdThreadPoke,
21
22
  cmdThreadRead,
@@ -415,6 +416,24 @@ thread
415
416
  });
416
417
  });
417
418
 
419
+ thread
420
+ .command("join")
421
+ .description("Block until a running thread finishes, then return the final result")
422
+ .argument("<thread-id>", "Thread ULID")
423
+ .option("--timeout <seconds>", "Max seconds to wait before giving up")
424
+ .action((threadId: string, opts: { timeout: string | undefined }) => {
425
+ const storageRoot = resolveStorageRoot();
426
+ runAction(async () => {
427
+ const timeoutMs = opts.timeout !== undefined ? Number(opts.timeout) * 1000 : null;
428
+ if (timeoutMs !== null && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
429
+ process.stderr.write("invalid --timeout: must be a positive number\n");
430
+ process.exit(1);
431
+ }
432
+ const results = await cmdThreadJoin(storageRoot, threadId, timeoutMs);
433
+ await writeOutput(toThreadExecPayload(results), "thread-exec", storageRoot);
434
+ });
435
+ });
436
+
418
437
  thread
419
438
  .command("read")
420
439
  .description("Read thread context as human-readable markdown")
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve as resolvePath } from "node:path";
3
4
  import { parse, stringify } from "yaml";
4
5
 
5
6
  /**
@@ -26,6 +27,7 @@ const VALID_CONFIG_KEYS: Record<
26
27
  knownFields: ["maxRunning"],
27
28
  minDepth: 2,
28
29
  },
30
+ workflowPaths: { nested: false },
29
31
  };
30
32
 
31
33
  /**
@@ -221,6 +223,31 @@ function parseArgsValue(value: string): unknown {
221
223
  throw new Error("Value for 'args' key must be a JSON array starting with '['");
222
224
  }
223
225
 
226
+ /**
227
+ * Parse value for a top-level string array key (must be JSON array of strings).
228
+ */
229
+ function parseStringArrayValue(value: string, keyName: string): unknown {
230
+ if (value.startsWith("[")) {
231
+ try {
232
+ const parsed = JSON.parse(value);
233
+ if (!Array.isArray(parsed)) {
234
+ throw new Error("Value must be an array");
235
+ }
236
+ for (const item of parsed) {
237
+ if (typeof item !== "string") {
238
+ throw new Error(`All items must be strings, got ${typeof item}`);
239
+ }
240
+ }
241
+ return parsed;
242
+ } catch (error) {
243
+ throw new Error(
244
+ `Invalid JSON array for ${keyName}: ${error instanceof Error ? error.message : String(error)}`,
245
+ );
246
+ }
247
+ }
248
+ throw new Error(`Value for '${keyName}' must be a JSON array starting with '['`);
249
+ }
250
+
224
251
  /**
225
252
  * Validate that we're not setting a property on a non-object
226
253
  */
@@ -265,9 +292,11 @@ export async function cmdConfigSet(
265
292
 
266
293
  const lastSegment = path[path.length - 1];
267
294
 
268
- // Parse value if it's for an array key (args)
295
+ // Parse value if it's for an array key (args, workflowPaths)
269
296
  let parsedValue: unknown = value;
270
- if (lastSegment === "args") {
297
+ if (path[0] === "workflowPaths") {
298
+ parsedValue = parseStringArrayValue(value, "workflowPaths");
299
+ } else if (lastSegment === "args") {
271
300
  parsedValue = parseArgsValue(value);
272
301
  } else if (lastSegment === "maxRunning") {
273
302
  const num = Number(value);
@@ -285,3 +314,45 @@ export async function cmdConfigSet(
285
314
 
286
315
  return { key, value: parsedValue };
287
316
  }
317
+
318
+ /**
319
+ * Expand leading `~/` in a path to the user's home directory.
320
+ */
321
+ function expandTilde(p: string): string {
322
+ if (p.startsWith("~/") || p === "~") {
323
+ return join(homedir(), p.slice(1));
324
+ }
325
+ return p;
326
+ }
327
+
328
+ /**
329
+ * Load workflowPaths from config and resolve to absolute paths.
330
+ * Returns empty array if config doesn't exist or key is missing.
331
+ */
332
+ export function loadWorkflowPaths(storageRoot: string): string[] {
333
+ const configPath = getConfigPath(storageRoot);
334
+ if (!existsSync(configPath)) {
335
+ return [];
336
+ }
337
+
338
+ let config: Record<string, unknown>;
339
+ try {
340
+ config = loadConfig(configPath);
341
+ } catch {
342
+ return [];
343
+ }
344
+
345
+ const raw = config.workflowPaths;
346
+ if (!Array.isArray(raw)) {
347
+ return [];
348
+ }
349
+
350
+ const result: string[] = [];
351
+ for (const item of raw) {
352
+ if (typeof item === "string" && item.trim() !== "") {
353
+ const expanded = expandTilde(item.trim());
354
+ result.push(resolvePath(expanded));
355
+ }
356
+ }
357
+ return result;
358
+ }