@united-workforce/cli 0.4.0 → 0.6.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 (223) hide show
  1. package/README.md +30 -3
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/adapter-json-roundtrip.test.js +16 -6
  4. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  5. package/dist/__tests__/concurrency.test.d.ts +2 -0
  6. package/dist/__tests__/concurrency.test.d.ts.map +1 -0
  7. package/dist/__tests__/concurrency.test.js +196 -0
  8. package/dist/__tests__/concurrency.test.js.map +1 -0
  9. package/dist/__tests__/config-text-renderer.test.d.ts +2 -0
  10. package/dist/__tests__/config-text-renderer.test.d.ts.map +1 -0
  11. package/dist/__tests__/config-text-renderer.test.js +137 -0
  12. package/dist/__tests__/config-text-renderer.test.js.map +1 -0
  13. package/dist/__tests__/e2e-mock-agent.test.js +23 -7
  14. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  15. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  16. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  17. package/dist/__tests__/format-text-default.test.js +43 -0
  18. package/dist/__tests__/format-text-default.test.js.map +1 -0
  19. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  20. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  21. package/dist/__tests__/format-text-registry.test.js +158 -0
  22. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  23. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +1 -1
  24. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  25. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  26. package/dist/__tests__/log-text-renderer.test.js +265 -0
  27. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  28. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  29. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  30. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  31. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  32. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  33. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  34. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  35. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  36. package/dist/__tests__/pid-recycling.test.js +9 -7
  37. package/dist/__tests__/pid-recycling.test.js.map +1 -1
  38. package/dist/__tests__/prompt.test.js +46 -4
  39. package/dist/__tests__/prompt.test.js.map +1 -1
  40. package/dist/__tests__/resolve-head-hash.test.js +8 -0
  41. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  42. package/dist/__tests__/solve-issue-tea-worktree.test.js +3 -1
  43. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  44. package/dist/__tests__/step-ask.test.js +9 -1
  45. package/dist/__tests__/step-ask.test.js.map +1 -1
  46. package/dist/__tests__/store-unified-threads.test.js +19 -17
  47. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  48. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts +2 -0
  49. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts.map +1 -0
  50. package/dist/__tests__/thread-agent-failure-suspended.test.js +332 -0
  51. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -0
  52. package/dist/__tests__/thread-cancel-status.test.js +19 -13
  53. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  54. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  55. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  56. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  57. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  58. package/dist/__tests__/thread-join.test.d.ts +2 -0
  59. package/dist/__tests__/thread-join.test.d.ts.map +1 -0
  60. package/dist/__tests__/thread-join.test.js +77 -0
  61. package/dist/__tests__/thread-join.test.js.map +1 -0
  62. package/dist/__tests__/thread-list-filters.test.js +10 -8
  63. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  64. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  65. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  66. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  67. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  68. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  69. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  70. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  71. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  72. package/dist/__tests__/thread-poke.test.js +15 -2
  73. package/dist/__tests__/thread-poke.test.js.map +1 -1
  74. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  75. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  76. package/dist/__tests__/thread-resume.test.js +11 -1
  77. package/dist/__tests__/thread-resume.test.js.map +1 -1
  78. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  79. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  80. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  81. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  82. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  83. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  84. package/dist/__tests__/thread-suspend-step.test.js +5 -2
  85. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  86. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  87. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  88. package/dist/__tests__/thread-test-helpers.js +13 -0
  89. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  90. package/dist/__tests__/thread.test.js +11 -9
  91. package/dist/__tests__/thread.test.js.map +1 -1
  92. package/dist/__tests__/validate-semantic.test.js +56 -2
  93. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  94. package/dist/__tests__/workflow-list-recursive.test.js +10 -7
  95. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -1
  96. package/dist/__tests__/workflow-paths.test.d.ts +2 -0
  97. package/dist/__tests__/workflow-paths.test.d.ts.map +1 -0
  98. package/dist/__tests__/workflow-paths.test.js +261 -0
  99. package/dist/__tests__/workflow-paths.test.js.map +1 -0
  100. package/dist/__tests__/workflow-resolution.test.js +10 -7
  101. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  102. package/dist/__tests__/workflow-show-resolution.test.js +10 -7
  103. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -1
  104. package/dist/__tests__/workflow-validate.test.js +75 -55
  105. package/dist/__tests__/workflow-validate.test.js.map +1 -1
  106. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  107. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  108. package/dist/__tests__/write-envelope.test.js +201 -0
  109. package/dist/__tests__/write-envelope.test.js.map +1 -0
  110. package/dist/cli.js +76 -36
  111. package/dist/cli.js.map +1 -1
  112. package/dist/commands/config.d.ts +5 -0
  113. package/dist/commands/config.d.ts.map +1 -1
  114. package/dist/commands/config.js +81 -3
  115. package/dist/commands/config.js.map +1 -1
  116. package/dist/commands/prompt.d.ts.map +1 -1
  117. package/dist/commands/prompt.js +42 -29
  118. package/dist/commands/prompt.js.map +1 -1
  119. package/dist/commands/setup.d.ts +9 -4
  120. package/dist/commands/setup.d.ts.map +1 -1
  121. package/dist/commands/setup.js +51 -7
  122. package/dist/commands/setup.js.map +1 -1
  123. package/dist/commands/thread.d.ts +12 -0
  124. package/dist/commands/thread.d.ts.map +1 -1
  125. package/dist/commands/thread.js +226 -9
  126. package/dist/commands/thread.js.map +1 -1
  127. package/dist/commands/workflow.d.ts +2 -2
  128. package/dist/commands/workflow.d.ts.map +1 -1
  129. package/dist/commands/workflow.js +26 -10
  130. package/dist/commands/workflow.js.map +1 -1
  131. package/dist/concurrency/concurrency.d.ts +34 -0
  132. package/dist/concurrency/concurrency.d.ts.map +1 -0
  133. package/dist/concurrency/concurrency.js +216 -0
  134. package/dist/concurrency/concurrency.js.map +1 -0
  135. package/dist/concurrency/index.d.ts +3 -0
  136. package/dist/concurrency/index.d.ts.map +1 -0
  137. package/dist/concurrency/index.js +2 -0
  138. package/dist/concurrency/index.js.map +1 -0
  139. package/dist/concurrency/types.d.ts +19 -0
  140. package/dist/concurrency/types.d.ts.map +1 -0
  141. package/dist/concurrency/types.js +2 -0
  142. package/dist/concurrency/types.js.map +1 -0
  143. package/dist/format.d.ts +69 -2
  144. package/dist/format.d.ts.map +1 -1
  145. package/dist/format.js +198 -1
  146. package/dist/format.js.map +1 -1
  147. package/dist/output-mappers.d.ts +122 -0
  148. package/dist/output-mappers.d.ts.map +1 -0
  149. package/dist/output-mappers.js +134 -0
  150. package/dist/output-mappers.js.map +1 -0
  151. package/dist/schemas.d.ts +4 -1
  152. package/dist/schemas.d.ts.map +1 -1
  153. package/dist/schemas.js +31 -4
  154. package/dist/schemas.js.map +1 -1
  155. package/dist/store.d.ts +11 -0
  156. package/dist/store.d.ts.map +1 -1
  157. package/dist/store.js +20 -1
  158. package/dist/store.js.map +1 -1
  159. package/dist/text-renderers.d.ts +30 -0
  160. package/dist/text-renderers.d.ts.map +1 -0
  161. package/dist/text-renderers.js +251 -0
  162. package/dist/text-renderers.js.map +1 -0
  163. package/dist/validate-semantic.d.ts.map +1 -1
  164. package/dist/validate-semantic.js +28 -11
  165. package/dist/validate-semantic.js.map +1 -1
  166. package/examples/brainstorm.yaml +130 -0
  167. package/examples/debate.yaml +169 -0
  168. package/examples/socratic-questioning.yaml +112 -0
  169. package/package.json +12 -11
  170. package/src/__tests__/adapter-json-roundtrip.test.ts +15 -6
  171. package/src/__tests__/concurrency.test.ts +266 -0
  172. package/src/__tests__/config-text-renderer.test.ts +156 -0
  173. package/src/__tests__/e2e-mock-agent.test.ts +45 -7
  174. package/src/__tests__/format-text-default.test.ts +49 -0
  175. package/src/__tests__/format-text-registry.test.ts +173 -0
  176. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +1 -1
  177. package/src/__tests__/log-text-renderer.test.ts +294 -0
  178. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  179. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  180. package/src/__tests__/pid-recycling.test.ts +9 -8
  181. package/src/__tests__/prompt.test.ts +48 -4
  182. package/src/__tests__/resolve-head-hash.test.ts +7 -0
  183. package/src/__tests__/solve-issue-tea-worktree.test.ts +3 -1
  184. package/src/__tests__/step-ask.test.ts +8 -1
  185. package/src/__tests__/store-unified-threads.test.ts +21 -18
  186. package/src/__tests__/thread-agent-failure-suspended.test.ts +406 -0
  187. package/src/__tests__/thread-cancel-status.test.ts +21 -14
  188. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  189. package/src/__tests__/thread-join.test.ts +103 -0
  190. package/src/__tests__/thread-list-filters.test.ts +9 -9
  191. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  192. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  193. package/src/__tests__/thread-poke.test.ts +14 -2
  194. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  195. package/src/__tests__/thread-resume.test.ts +10 -1
  196. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  197. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  198. package/src/__tests__/thread-suspend-step.test.ts +5 -2
  199. package/src/__tests__/thread-test-helpers.ts +15 -1
  200. package/src/__tests__/thread.test.ts +10 -10
  201. package/src/__tests__/validate-semantic.test.ts +59 -2
  202. package/src/__tests__/workflow-list-recursive.test.ts +9 -9
  203. package/src/__tests__/workflow-paths.test.ts +337 -0
  204. package/src/__tests__/workflow-resolution.test.ts +9 -8
  205. package/src/__tests__/workflow-show-resolution.test.ts +9 -8
  206. package/src/__tests__/workflow-validate.test.ts +78 -56
  207. package/src/__tests__/write-envelope.test.ts +257 -0
  208. package/src/cli.ts +111 -35
  209. package/src/commands/config.ts +85 -3
  210. package/src/commands/prompt.ts +42 -29
  211. package/src/commands/setup.ts +57 -7
  212. package/src/commands/thread.ts +280 -9
  213. package/src/commands/workflow.ts +32 -11
  214. package/src/concurrency/concurrency.ts +245 -0
  215. package/src/concurrency/index.ts +10 -0
  216. package/src/concurrency/types.ts +19 -0
  217. package/src/format.ts +282 -2
  218. package/src/output-mappers.ts +255 -0
  219. package/src/schemas.ts +39 -3
  220. package/src/store.ts +25 -1
  221. package/src/text-renderers.ts +355 -0
  222. package/src/validate-semantic.ts +33 -12
  223. package/LICENSE +0 -21
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { formatOutput, getTextRenderer, TEXT_RENDERERS } from "../format.js";
3
+ import { renderThreadCancel } from "../text-renderers.js";
4
+
5
+ describe("thread cancel — text renderer registration", () => {
6
+ test("TEXT_RENDERERS contains 'thread cancel'", () => {
7
+ expect(getTextRenderer("thread cancel")).toBeDefined();
8
+ expect(typeof getTextRenderer("thread cancel")).toBe("function");
9
+ });
10
+
11
+ test("TEXT_RENDERERS['thread cancel'] is the same reference as renderThreadCancel", () => {
12
+ expect(TEXT_RENDERERS["thread cancel"]).toBe(renderThreadCancel);
13
+ });
14
+
15
+ test("renderThreadCancel is exported from text-renderers.ts", () => {
16
+ expect(typeof renderThreadCancel).toBe("function");
17
+ });
18
+ });
19
+
20
+ describe("renderThreadCancel — output shape", () => {
21
+ test("returns a string for full payload", () => {
22
+ const out = renderThreadCancel({
23
+ thread: "01JTEST000000000000CANCEL1",
24
+ cancelled: true,
25
+ });
26
+ expect(typeof out).toBe("string");
27
+ });
28
+
29
+ test("includes the cancelled thread's ULID", () => {
30
+ const out = renderThreadCancel({
31
+ thread: "01JTEST000000000000CANCEL1",
32
+ cancelled: true,
33
+ });
34
+ expect(out).toContain("01JTEST000000000000CANCEL1");
35
+ });
36
+
37
+ test("indicates cancelled status (Status: cancelled OR Cancelled: yes)", () => {
38
+ const out = renderThreadCancel({
39
+ thread: "01JTEST000000000000CANCEL1",
40
+ cancelled: true,
41
+ });
42
+ // accept either rendering style
43
+ const lower = out.toLowerCase();
44
+ const hasCancelMarker = lower.includes("cancelled") || lower.includes("yes");
45
+ expect(hasCancelMarker).toBe(true);
46
+ });
47
+
48
+ test("does NOT begin with '{' or '[' (not raw JSON)", () => {
49
+ const out = renderThreadCancel({
50
+ thread: "01JTEST000000000000CANCEL1",
51
+ cancelled: true,
52
+ });
53
+ const trimmed = out.trimStart();
54
+ expect(trimmed.startsWith("{")).toBe(false);
55
+ expect(trimmed.startsWith("[")).toBe(false);
56
+ });
57
+
58
+ test("does NOT contain literal 'undefined'", () => {
59
+ const out = renderThreadCancel({
60
+ thread: "01JTEST000000000000CANCEL1",
61
+ cancelled: true,
62
+ });
63
+ expect(out).not.toContain("undefined");
64
+ });
65
+ });
66
+
67
+ describe("renderThreadCancel — partial / missing data", () => {
68
+ test("missing 'cancelled' field — returns string, no throw, no 'undefined'", () => {
69
+ const out = renderThreadCancel({ thread: "01JTEST000000000000CANCEL1" });
70
+ expect(typeof out).toBe("string");
71
+ expect(out).not.toContain("undefined");
72
+ });
73
+
74
+ test("missing 'thread' field — returns string, no throw, no 'undefined'", () => {
75
+ const out = renderThreadCancel({ cancelled: true });
76
+ expect(typeof out).toBe("string");
77
+ expect(out).not.toContain("undefined");
78
+ });
79
+
80
+ test("empty object — returns string, no throw, no 'undefined'", () => {
81
+ const out = renderThreadCancel({});
82
+ expect(typeof out).toBe("string");
83
+ expect(out).not.toContain("undefined");
84
+ });
85
+
86
+ test("null payload — returns string, no throw", () => {
87
+ expect(() => renderThreadCancel(null)).not.toThrow();
88
+ const out = renderThreadCancel(null);
89
+ expect(typeof out).toBe("string");
90
+ expect(out).not.toContain("undefined");
91
+ });
92
+
93
+ test("non-object payload (string) — returns string, no throw", () => {
94
+ expect(() => renderThreadCancel("oops")).not.toThrow();
95
+ const out = renderThreadCancel("oops");
96
+ expect(typeof out).toBe("string");
97
+ expect(out).not.toContain("undefined");
98
+ });
99
+ });
100
+
101
+ describe("formatOutput integration — thread cancel", () => {
102
+ test("formatOutput(data, 'text', 'thread cancel') uses renderer", () => {
103
+ const data = { thread: "01JTEST000000000000CANCEL1", cancelled: true };
104
+ const out = formatOutput(data, "text", "thread cancel");
105
+ expect(typeof out).toBe("string");
106
+ expect(out).not.toContain("undefined");
107
+ expect(out.trimStart().startsWith("{")).toBe(false);
108
+ expect(out).toContain("01JTEST000000000000CANCEL1");
109
+ });
110
+
111
+ test("formatOutput(data, 'json', 'thread cancel') still emits parseable JSON", () => {
112
+ const data = { thread: "01JTEST000000000000CANCEL1", cancelled: true };
113
+ const out = formatOutput(data, "json", "thread cancel");
114
+ const parsed = JSON.parse(out);
115
+ expect(parsed).toEqual(data);
116
+ });
117
+
118
+ test("formatOutput(data, 'yaml', 'thread cancel') still emits YAML", () => {
119
+ const data = { thread: "01JTEST000000000000CANCEL1", cancelled: true };
120
+ const out = formatOutput(data, "yaml", "thread cancel");
121
+ expect(typeof out).toBe("string");
122
+ expect(out).toContain("thread:");
123
+ expect(out).toContain("cancelled:");
124
+ });
125
+ });
@@ -0,0 +1,103 @@
1
+ import { mkdir, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CasRef, ThreadId } from "@united-workforce/protocol";
5
+ import { createThreadIndexEntry } from "@united-workforce/protocol";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import {
8
+ createMarker,
9
+ deleteMarker,
10
+ getProcessStartTime,
11
+ isThreadRunning,
12
+ } from "../background/index.js";
13
+ import { cmdThreadJoin } from "../commands/thread.js";
14
+ import { makeUwfStore, seedThread } from "./thread-test-helpers.js";
15
+
16
+ describe("cmdThreadJoin", () => {
17
+ let storageRoot: string;
18
+ let savedOcasHome: string | undefined;
19
+
20
+ beforeEach(async () => {
21
+ savedOcasHome = process.env.OCAS_HOME;
22
+ storageRoot = join(
23
+ tmpdir(),
24
+ `uwf-test-join-${Date.now()}-${Math.random().toString(36).slice(2)}`,
25
+ );
26
+ await mkdir(storageRoot, { recursive: true });
27
+ });
28
+
29
+ afterEach(async () => {
30
+ if (savedOcasHome !== undefined) {
31
+ process.env.OCAS_HOME = savedOcasHome;
32
+ } else {
33
+ delete process.env.OCAS_HOME;
34
+ }
35
+ await rm(storageRoot, { recursive: true, force: true });
36
+ });
37
+
38
+ it("throws when thread does not exist", async () => {
39
+ await makeUwfStore(storageRoot);
40
+ const threadId = "01JF0000000000NOTEXIST0" as ThreadId;
41
+ await expect(cmdThreadJoin(storageRoot, threadId, null)).rejects.toThrow(
42
+ /thread not found|process\.exit/,
43
+ );
44
+ });
45
+
46
+ it("times out when thread keeps running", async () => {
47
+ const threadId = "01JF0000000000TESTJOIN03" as ThreadId;
48
+ await makeUwfStore(storageRoot);
49
+ // Seed a thread so existence check passes
50
+ const uwf = await makeUwfStore(storageRoot);
51
+ const head = (await uwf.store.cas.put(uwf.schemas.text, "join-timeout-test")) as CasRef;
52
+ await seedThread(storageRoot, threadId, createThreadIndexEntry(head));
53
+
54
+ // Create a running marker with our PID (it will stay alive)
55
+ await createMarker(storageRoot, {
56
+ thread: threadId,
57
+ workflow: "AAAAAAAAAAAAA" as CasRef,
58
+ pid: process.pid,
59
+ startedAt: Date.now(),
60
+ processStartTime: getProcessStartTime(process.pid),
61
+ });
62
+
63
+ // Timeout after 100ms — should fail because marker never disappears
64
+ await expect(cmdThreadJoin(storageRoot, threadId, 100)).rejects.toThrow(
65
+ /join timed out|process\.exit/,
66
+ );
67
+
68
+ // Cleanup
69
+ await deleteMarker(storageRoot, threadId);
70
+ });
71
+
72
+ it("poll loop exits when marker is removed", async () => {
73
+ const threadId = "01JF0000000000TESTJOIN04" as ThreadId;
74
+ const uwf = await makeUwfStore(storageRoot);
75
+ const head = (await uwf.store.cas.put(uwf.schemas.text, "join-poll-test")) as CasRef;
76
+ await seedThread(storageRoot, threadId, createThreadIndexEntry(head));
77
+
78
+ // Create a running marker
79
+ await createMarker(storageRoot, {
80
+ thread: threadId,
81
+ workflow: "AAAAAAAAAAAAA" as CasRef,
82
+ pid: process.pid,
83
+ startedAt: Date.now(),
84
+ processStartTime: getProcessStartTime(process.pid),
85
+ });
86
+
87
+ // Confirm marker is valid
88
+ expect(await isThreadRunning(storageRoot, threadId)).not.toBeNull();
89
+
90
+ // Remove it after a short delay — simulates background worker finishing
91
+ setTimeout(() => {
92
+ deleteMarker(storageRoot, threadId);
93
+ }, 300);
94
+
95
+ // cmdThreadJoin will poll and wait. It will exit the poll loop after marker
96
+ // disappears, then try to resolve workflow from head. Our simple text node
97
+ // won't resolve, so it will fail — but the key test is that the poll loop
98
+ // DID exit (it didn't time out). We use a generous timeout to prove this.
99
+ await expect(cmdThreadJoin(storageRoot, threadId, 5000)).rejects.toThrow(
100
+ /failed to resolve workflow|process\.exit/,
101
+ );
102
+ });
103
+ });
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { CasRef, ThreadId } from "@united-workforce/protocol";
@@ -16,17 +16,10 @@ import {
16
16
  saveWorkflowRegistry,
17
17
  setThread,
18
18
  } from "../store.js";
19
+ import { makeUwfStore } from "./thread-test-helpers.js";
19
20
 
20
21
  // ── helpers ───────────────────────────────────────────────────────────────────
21
22
 
22
- async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
23
- const casDir = join(storageRoot, "cas");
24
- await mkdir(casDir, { recursive: true });
25
- // Set OCAS_HOME to use the test's CAS directory
26
- process.env.OCAS_HOME = casDir;
27
- return createUwfStore(storageRoot);
28
- }
29
-
30
23
  async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
31
24
  const workflowPayload = {
32
25
  name: "test-workflow",
@@ -84,12 +77,19 @@ async function completeThread(
84
77
  // ── test setup ────────────────────────────────────────────────────────────────
85
78
 
86
79
  let tmpDir: string;
80
+ let savedOcasHome: string | undefined;
87
81
 
88
82
  beforeEach(async () => {
83
+ savedOcasHome = process.env.OCAS_HOME;
89
84
  tmpDir = await mkdtemp(join(tmpdir(), "thread-list-filters-test-"));
90
85
  });
91
86
 
92
87
  afterEach(async () => {
88
+ if (savedOcasHome === undefined) {
89
+ delete process.env.OCAS_HOME;
90
+ } else {
91
+ process.env.OCAS_HOME = savedOcasHome;
92
+ }
93
93
  await rm(tmpDir, { recursive: true, force: true });
94
94
  });
95
95
 
@@ -0,0 +1,110 @@
1
+ import { OUTPUT_TEMPLATES } from "@united-workforce/protocol";
2
+ import { Liquid } from "liquidjs";
3
+ import { describe, expect, test } from "vitest";
4
+
5
+ /**
6
+ * Issue #351 — `uwf thread list --format text` rendered the `STARTED` column
7
+ * as `58414-12-06` because `THREAD_LIST_TEMPLATE` piped `item.startedAt` (Unix
8
+ * **ms** per `THREAD_LIST_OUTPUT_SCHEMA`) directly into LiquidJS's `| date`
9
+ * filter, which expects Unix **seconds**.
10
+ *
11
+ * This integration test renders the template against a known ms timestamp
12
+ * and asserts the year falls within the realistic 20xx range, confirming
13
+ * the ms→s conversion is in place at the protocol layer.
14
+ */
15
+
16
+ function makeEngine(): Liquid {
17
+ return new Liquid({ cache: false, strictFilters: false, strictVariables: false });
18
+ }
19
+
20
+ describe("THREAD_LIST_TEMPLATE rendering — issue #351 ms→s for `| date`", () => {
21
+ test("renders item.startedAt=1781229932779 as a 2026 calendar date (not 58414)", async () => {
22
+ const engine = makeEngine();
23
+ const out = await engine.parseAndRender(OUTPUT_TEMPLATES["thread-list"], {
24
+ items: [
25
+ {
26
+ threadId: "01K5HMKZQB7VDA8E2K9P3R5XBC",
27
+ workflowHash: "WF1234567890A",
28
+ workflowName: null,
29
+ status: "idle",
30
+ currentRole: "planner",
31
+ startedAt: 1781229932779,
32
+ completedAt: null,
33
+ },
34
+ ],
35
+ });
36
+
37
+ expect(out).not.toContain("58414");
38
+ expect(out).toMatch(/\b20\d{2}-\d{2}-\d{2}\b/);
39
+ // The STARTED cell must NOT begin with a 5-digit year.
40
+ expect(out).not.toMatch(/\b\d{5}-\d{2}-\d{2}\b/);
41
+ });
42
+
43
+ test("renders `-` for items with startedAt=null (null guard preserved)", async () => {
44
+ const engine = makeEngine();
45
+ const out = await engine.parseAndRender(OUTPUT_TEMPLATES["thread-list"], {
46
+ items: [
47
+ {
48
+ threadId: "01K5HMKZQB7VDA8E2K9P3R5XBC",
49
+ workflowHash: "WF1234567890A",
50
+ workflowName: null,
51
+ status: "idle",
52
+ currentRole: "planner",
53
+ startedAt: null,
54
+ completedAt: null,
55
+ },
56
+ ],
57
+ });
58
+
59
+ expect(out).not.toContain("58414");
60
+ expect(out).not.toContain("Invalid Date");
61
+ expect(out).not.toContain("1970-01-01");
62
+ // Last token of the row is the rendered STARTED cell — must be `-`.
63
+ const dataRow = out
64
+ .split("\n")
65
+ .find((line: string) => line.includes("01K5HMKZQB7VDA8E2K9P3R5XBC"));
66
+ expect(dataRow).toBeDefined();
67
+ expect(dataRow?.trimEnd().endsWith("-")).toBe(true);
68
+ });
69
+
70
+ test("renders multiple ms timestamps across years 2020–2030 with correct year prefix", async () => {
71
+ const engine = makeEngine();
72
+ const items = [
73
+ {
74
+ threadId: "ID1",
75
+ workflowHash: "WF",
76
+ workflowName: null,
77
+ status: "idle",
78
+ currentRole: null,
79
+ startedAt: Date.UTC(2020, 0, 1, 0, 0, 0),
80
+ completedAt: null,
81
+ },
82
+ {
83
+ threadId: "ID2",
84
+ workflowHash: "WF",
85
+ workflowName: null,
86
+ status: "idle",
87
+ currentRole: null,
88
+ startedAt: Date.UTC(2026, 5, 12, 5, 25, 0),
89
+ completedAt: null,
90
+ },
91
+ {
92
+ threadId: "ID3",
93
+ workflowHash: "WF",
94
+ workflowName: null,
95
+ status: "idle",
96
+ currentRole: null,
97
+ startedAt: Date.UTC(2030, 11, 31, 23, 59, 0),
98
+ completedAt: null,
99
+ },
100
+ ];
101
+
102
+ const out = await engine.parseAndRender(OUTPUT_TEMPLATES["thread-list"], { items });
103
+
104
+ expect(out).toContain("2020-");
105
+ expect(out).toContain("2026-");
106
+ expect(out).toContain("2030-");
107
+ expect(out).not.toContain("58414");
108
+ expect(out).not.toMatch(/\b\d{5}-\d{2}-\d{2}\b/);
109
+ });
110
+ });
@@ -0,0 +1,198 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CasRef, ThreadId } from "@united-workforce/protocol";
5
+ import { createThreadIndexEntry } from "@united-workforce/protocol";
6
+ import { generateUlid } from "@united-workforce/util";
7
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
8
+ import { cmdThreadList } from "../commands/thread.js";
9
+ import type { UwfStore } from "../store.js";
10
+ import { completeThread as completeThreadInStore, setThread } from "../store.js";
11
+ import { makeUwfStore } from "./thread-test-helpers.js";
12
+
13
+ // ── helpers ───────────────────────────────────────────────────────────────────
14
+
15
+ async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
16
+ const workflowPayload = {
17
+ name: "test-workflow",
18
+ roles: {
19
+ role1: {
20
+ goal: "test goal",
21
+ outputSchema: { type: "object" as const, properties: {} },
22
+ },
23
+ },
24
+ graph: { start: "role1" },
25
+ conditions: {},
26
+ };
27
+ return await uwf.store.cas.put(uwf.schemas.workflow, workflowPayload);
28
+ }
29
+
30
+ async function createTestThread(
31
+ uwf: UwfStore,
32
+ storageRoot: string,
33
+ workflowHash: CasRef,
34
+ timestamp: number,
35
+ ): Promise<ThreadId> {
36
+ const threadId = generateUlid(timestamp) as ThreadId;
37
+ const startPayload = {
38
+ workflow: workflowHash,
39
+ prompt: "test prompt",
40
+ cwd: storageRoot,
41
+ };
42
+ const headHash = await uwf.store.cas.put(uwf.schemas.startNode, startPayload);
43
+ setThread(uwf.varStore, threadId, createThreadIndexEntry(headHash));
44
+ return threadId;
45
+ }
46
+
47
+ // ── test setup ────────────────────────────────────────────────────────────────
48
+
49
+ let tmpDir: string;
50
+ let savedOcasHome: string | undefined;
51
+
52
+ beforeEach(async () => {
53
+ savedOcasHome = process.env.OCAS_HOME;
54
+ tmpDir = await mkdtemp(join(tmpdir(), "thread-list-workflow-corrupt-test-"));
55
+ });
56
+
57
+ afterEach(async () => {
58
+ if (savedOcasHome === undefined) {
59
+ delete process.env.OCAS_HOME;
60
+ } else {
61
+ process.env.OCAS_HOME = savedOcasHome;
62
+ }
63
+ await rm(tmpDir, { recursive: true, force: true });
64
+ });
65
+
66
+ // ── issue #326: loadWorkflowPayload throws instead of process.exit ───────────
67
+
68
+ describe("loadWorkflowPayload throws on error (#326)", () => {
69
+ test("active thread with missing workflow CAS node appears as corrupt", async () => {
70
+ const uwf = await makeUwfStore(tmpDir);
71
+ const workflowHash = await createTestWorkflow(uwf);
72
+ const now = Date.now();
73
+
74
+ // Create a valid thread
75
+ const validId = await createTestThread(uwf, tmpDir, workflowHash, now);
76
+
77
+ // Create a thread with a different workflow, then delete it
78
+ const otherWorkflowPayload = {
79
+ name: "other-workflow",
80
+ roles: {
81
+ role1: {
82
+ goal: "other goal",
83
+ outputSchema: { type: "object" as const, properties: {} },
84
+ },
85
+ },
86
+ graph: { start: "role1" },
87
+ conditions: {},
88
+ };
89
+ const otherWorkflowHash = await uwf.store.cas.put(uwf.schemas.workflow, otherWorkflowPayload);
90
+ const corruptId = await createTestThread(uwf, tmpDir, otherWorkflowHash, now + 1000);
91
+
92
+ // Delete the other workflow CAS node — start node still exists but workflow ref dangles
93
+ uwf.store.cas.delete(otherWorkflowHash);
94
+
95
+ // thread list should NOT crash — corrupt thread appears with status: "corrupt"
96
+ const result = await cmdThreadList(tmpDir, null, null, null, null, null, true);
97
+
98
+ expect(result.length).toBe(2);
99
+
100
+ const validItem = result.find((r) => r.thread === validId);
101
+ expect(validItem).toBeDefined();
102
+ expect(validItem!.status).toBe("idle");
103
+
104
+ const corruptItem = result.find((r) => r.thread === corruptId);
105
+ expect(corruptItem).toBeDefined();
106
+ expect(corruptItem!.status).toBe("corrupt");
107
+ expect(corruptItem!.statusDisplay).toBe("corrupt");
108
+ });
109
+
110
+ test("active thread with wrong-type workflow CAS node appears as corrupt", async () => {
111
+ const uwf = await makeUwfStore(tmpDir);
112
+ const now = Date.now();
113
+
114
+ // Create a valid workflow and thread
115
+ const workflowHash = await createTestWorkflow(uwf);
116
+ const validId = await createTestThread(uwf, tmpDir, workflowHash, now);
117
+
118
+ // Create a non-workflow CAS node (text type) and use its hash as a workflow ref
119
+ const wrongTypeHash = await uwf.store.cas.put(uwf.schemas.text, "not a workflow");
120
+
121
+ // Create a thread whose start node points to the wrong-type CAS node
122
+ const corruptId = generateUlid(now + 1000) as ThreadId;
123
+ const startPayload = {
124
+ workflow: wrongTypeHash,
125
+ prompt: "corrupt thread with wrong type workflow",
126
+ cwd: tmpDir,
127
+ };
128
+ const headHash = await uwf.store.cas.put(uwf.schemas.startNode, startPayload);
129
+ setThread(uwf.varStore, corruptId, createThreadIndexEntry(headHash));
130
+
131
+ // thread list should NOT crash — wrong-type thread appears as corrupt
132
+ const result = await cmdThreadList(tmpDir, null, null, null, null, null, true);
133
+
134
+ expect(result.length).toBe(2);
135
+
136
+ const validItem = result.find((r) => r.thread === validId);
137
+ expect(validItem).toBeDefined();
138
+ expect(validItem!.status).toBe("idle");
139
+
140
+ const corruptItem = result.find((r) => r.thread === corruptId);
141
+ expect(corruptItem).toBeDefined();
142
+ expect(corruptItem!.status).toBe("corrupt");
143
+ expect(corruptItem!.statusDisplay).toBe("corrupt");
144
+ });
145
+
146
+ test("completed thread with missing workflow CAS node retains stored status with --all", async () => {
147
+ const uwf = await makeUwfStore(tmpDir);
148
+ const now = Date.now();
149
+
150
+ // Create two separate workflows so we can corrupt one without affecting the other
151
+ const activeWorkflowHash = await createTestWorkflow(uwf);
152
+ const completedWorkflowPayload = {
153
+ name: "completed-workflow",
154
+ roles: {
155
+ role1: {
156
+ goal: "completed goal",
157
+ outputSchema: { type: "object" as const, properties: {} },
158
+ },
159
+ },
160
+ graph: { start: "role1" },
161
+ conditions: {},
162
+ };
163
+ const completedWorkflowHash = await uwf.store.cas.put(
164
+ uwf.schemas.workflow,
165
+ completedWorkflowPayload,
166
+ );
167
+
168
+ // Create a valid active thread
169
+ const activeId = await createTestThread(uwf, tmpDir, activeWorkflowHash, now);
170
+
171
+ // Create a thread and complete it
172
+ const completedId = await createTestThread(uwf, tmpDir, completedWorkflowHash, now + 1000);
173
+ completeThreadInStore(uwf.varStore, completedId, "end");
174
+
175
+ // Delete only the completed thread's workflow CAS node
176
+ uwf.store.cas.delete(completedWorkflowHash);
177
+
178
+ // thread list --all should NOT crash
179
+ const result = await cmdThreadList(tmpDir, null, null, null, null, null, true);
180
+
181
+ expect(result.length).toBe(2);
182
+
183
+ // Active thread is still valid (its workflow exists)
184
+ const activeItem = result.find((r) => r.thread === activeId);
185
+ expect(activeItem).toBeDefined();
186
+ expect(activeItem!.status).toBe("idle");
187
+
188
+ // Completed thread retains its stored status — collectCompletedThreads only calls
189
+ // resolveWorkflowFromHead (returns ref from start node) and never loads the workflow CAS node
190
+ const completedItem = result.find((r) => r.thread === completedId);
191
+ expect(completedItem).toBeDefined();
192
+ expect(completedItem!.status).toBe("end");
193
+ expect(completedItem!.statusDisplay).toBe("end");
194
+ // workflowName is null because the workflow ref won't match a registry entry
195
+ // (the deleted workflow was never registered)
196
+ expect(completedItem!.workflowName).toBeNull();
197
+ });
198
+ });
@@ -28,12 +28,19 @@ const OUTPUT_SCHEMA = {
28
28
  const THREAD_ID = "01POKESTEPTEST00000000" as ThreadId;
29
29
 
30
30
  let tmpDir: string;
31
+ let savedOcasHome: string | undefined;
31
32
 
32
33
  beforeEach(async () => {
34
+ savedOcasHome = process.env.OCAS_HOME;
33
35
  tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-poke-test-"));
34
36
  });
35
37
 
36
38
  afterEach(async () => {
39
+ if (savedOcasHome === undefined) {
40
+ delete process.env.OCAS_HOME;
41
+ } else {
42
+ process.env.OCAS_HOME = savedOcasHome;
43
+ }
37
44
  await rm(tmpDir, { recursive: true, force: true });
38
45
  });
39
46
 
@@ -271,8 +278,10 @@ function runUwf(
271
278
  casDir: string,
272
279
  ): { stdout: string; stderr: string; status: number } {
273
280
  const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "dist", "cli.js");
281
+ // Tests parse stdout as bare JSON; default --format text would break that.
282
+ const formatArgs = args.includes("--format") ? args : ["--format", "raw-json", ...args];
274
283
  try {
275
- const stdout = execFileSync(process.execPath, [cliPath, ...args], {
284
+ const stdout = execFileSync(process.execPath, [cliPath, ...formatArgs], {
276
285
  encoding: "utf8",
277
286
  stdio: ["ignore", "pipe", "pipe"],
278
287
  env: {
@@ -529,7 +538,7 @@ describe("uwf thread poke - edge cases", () => {
529
538
  expect(cliOutput.suspendMessage).toBeNull();
530
539
  });
531
540
 
532
- test("6.2 agent failure leaves thread head unchanged", async () => {
541
+ test("6.2 agent failure leaves thread head unchanged and suspends thread", async () => {
533
542
  const { casDir, oldStepHash, failingAgentPath } = await setupThread();
534
543
  const result = runUwf(
535
544
  ["thread", "poke", THREAD_ID, "-p", "redo", "--agent", failingAgentPath],
@@ -541,5 +550,8 @@ describe("uwf thread poke - edge cases", () => {
541
550
  const uwf = await createUwfStore(tmpDir);
542
551
  const entry = getThread(uwf.varStore, THREAD_ID);
543
552
  expect(entry?.head).toBe(oldStepHash);
553
+ expect(entry?.status).toBe("suspended");
554
+ expect(entry?.suspendedRole).toBe("worker");
555
+ expect(entry?.suspendMessage).toContain("agent command failed");
544
556
  });
545
557
  });
@@ -1,13 +1,11 @@
1
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { bootstrap, putSchema, type Store } from "@ocas/core";
5
5
  import type { CasRef, ThreadId } from "@united-workforce/protocol";
6
6
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
7
7
  import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
8
- import type { UwfStore } from "../store.js";
9
- import { createUwfStore } from "../store.js";
10
- import { seedThreads } from "./thread-test-helpers.js";
8
+ import { makeUwfStore, seedThreads } from "./thread-test-helpers.js";
11
9
 
12
10
  // ── schemas used in tests ────────────────────────────────────────────────────
13
11
 
@@ -49,13 +47,6 @@ const DETAIL_SCHEMA = {
49
47
 
50
48
  // ── helpers ───────────────────────────────────────────────────────────────────
51
49
 
52
- async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
53
- const casDir = join(storageRoot, "cas");
54
- await mkdir(casDir, { recursive: true });
55
- process.env.OCAS_HOME = casDir;
56
- return createUwfStore(storageRoot);
57
- }
58
-
59
50
  async function registerDetailSchemas(store: Store) {
60
51
  await bootstrap(store);
61
52
  const [turn, detail] = await Promise.all([
@@ -68,12 +59,19 @@ async function registerDetailSchemas(store: Store) {
68
59
  // ── fixture ───────────────────────────────────────────────────────────────────
69
60
 
70
61
  let tmpDir: string;
62
+ let savedOcasHome: string | undefined;
71
63
 
72
64
  beforeEach(async () => {
65
+ savedOcasHome = process.env.OCAS_HOME;
73
66
  tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
74
67
  });
75
68
 
76
69
  afterEach(async () => {
70
+ if (savedOcasHome === undefined) {
71
+ delete process.env.OCAS_HOME;
72
+ } else {
73
+ process.env.OCAS_HOME = savedOcasHome;
74
+ }
77
75
  await rm(tmpDir, { recursive: true, force: true });
78
76
  });
79
77