@united-workforce/cli 0.4.0 → 0.5.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 (193) 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__/e2e-mock-agent.test.js +23 -7
  10. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  11. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  12. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  13. package/dist/__tests__/format-text-default.test.js +43 -0
  14. package/dist/__tests__/format-text-default.test.js.map +1 -0
  15. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  16. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  17. package/dist/__tests__/format-text-registry.test.js +158 -0
  18. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  19. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  20. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  21. package/dist/__tests__/log-text-renderer.test.js +265 -0
  22. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  23. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  24. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  25. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  26. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  27. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  28. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  29. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  30. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  31. package/dist/__tests__/pid-recycling.test.js +9 -7
  32. package/dist/__tests__/pid-recycling.test.js.map +1 -1
  33. package/dist/__tests__/prompt.test.js +46 -4
  34. package/dist/__tests__/prompt.test.js.map +1 -1
  35. package/dist/__tests__/resolve-head-hash.test.js +8 -0
  36. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  37. package/dist/__tests__/solve-issue-tea-worktree.test.js +3 -1
  38. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  39. package/dist/__tests__/step-ask.test.js +9 -1
  40. package/dist/__tests__/step-ask.test.js.map +1 -1
  41. package/dist/__tests__/store-unified-threads.test.js +19 -17
  42. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  43. package/dist/__tests__/thread-cancel-status.test.js +19 -13
  44. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  45. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  46. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  47. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  48. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  49. package/dist/__tests__/thread-list-filters.test.js +10 -8
  50. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  51. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  52. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  53. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  54. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  55. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  56. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  57. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  58. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  59. package/dist/__tests__/thread-poke.test.js +11 -1
  60. package/dist/__tests__/thread-poke.test.js.map +1 -1
  61. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  62. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  63. package/dist/__tests__/thread-resume.test.js +11 -1
  64. package/dist/__tests__/thread-resume.test.js.map +1 -1
  65. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  66. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  67. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  68. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  69. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  70. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  71. package/dist/__tests__/thread-suspend-step.test.js +5 -2
  72. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  73. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  74. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  75. package/dist/__tests__/thread-test-helpers.js +13 -0
  76. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  77. package/dist/__tests__/thread.test.js +11 -9
  78. package/dist/__tests__/thread.test.js.map +1 -1
  79. package/dist/__tests__/validate-semantic.test.js +56 -2
  80. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  81. package/dist/__tests__/workflow-list-recursive.test.js +10 -7
  82. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -1
  83. package/dist/__tests__/workflow-resolution.test.js +10 -7
  84. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  85. package/dist/__tests__/workflow-show-resolution.test.js +10 -7
  86. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -1
  87. package/dist/__tests__/workflow-validate.test.js +75 -55
  88. package/dist/__tests__/workflow-validate.test.js.map +1 -1
  89. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  90. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  91. package/dist/__tests__/write-envelope.test.js +201 -0
  92. package/dist/__tests__/write-envelope.test.js.map +1 -0
  93. package/dist/cli.js +58 -35
  94. package/dist/cli.js.map +1 -1
  95. package/dist/commands/config.d.ts.map +1 -1
  96. package/dist/commands/config.js +12 -0
  97. package/dist/commands/config.js.map +1 -1
  98. package/dist/commands/prompt.d.ts.map +1 -1
  99. package/dist/commands/prompt.js +42 -29
  100. package/dist/commands/prompt.js.map +1 -1
  101. package/dist/commands/setup.d.ts +9 -4
  102. package/dist/commands/setup.d.ts.map +1 -1
  103. package/dist/commands/setup.js +51 -7
  104. package/dist/commands/setup.js.map +1 -1
  105. package/dist/commands/thread.d.ts.map +1 -1
  106. package/dist/commands/thread.js +44 -2
  107. package/dist/commands/thread.js.map +1 -1
  108. package/dist/commands/workflow.d.ts +1 -1
  109. package/dist/commands/workflow.d.ts.map +1 -1
  110. package/dist/commands/workflow.js +2 -6
  111. package/dist/commands/workflow.js.map +1 -1
  112. package/dist/concurrency/concurrency.d.ts +34 -0
  113. package/dist/concurrency/concurrency.d.ts.map +1 -0
  114. package/dist/concurrency/concurrency.js +216 -0
  115. package/dist/concurrency/concurrency.js.map +1 -0
  116. package/dist/concurrency/index.d.ts +3 -0
  117. package/dist/concurrency/index.d.ts.map +1 -0
  118. package/dist/concurrency/index.js +2 -0
  119. package/dist/concurrency/index.js.map +1 -0
  120. package/dist/concurrency/types.d.ts +19 -0
  121. package/dist/concurrency/types.d.ts.map +1 -0
  122. package/dist/concurrency/types.js +2 -0
  123. package/dist/concurrency/types.js.map +1 -0
  124. package/dist/format.d.ts +69 -2
  125. package/dist/format.d.ts.map +1 -1
  126. package/dist/format.js +198 -1
  127. package/dist/format.js.map +1 -1
  128. package/dist/output-mappers.d.ts +122 -0
  129. package/dist/output-mappers.d.ts.map +1 -0
  130. package/dist/output-mappers.js +134 -0
  131. package/dist/output-mappers.js.map +1 -0
  132. package/dist/schemas.d.ts +4 -1
  133. package/dist/schemas.d.ts.map +1 -1
  134. package/dist/schemas.js +31 -4
  135. package/dist/schemas.js.map +1 -1
  136. package/dist/text-renderers.d.ts +30 -0
  137. package/dist/text-renderers.d.ts.map +1 -0
  138. package/dist/text-renderers.js +251 -0
  139. package/dist/text-renderers.js.map +1 -0
  140. package/dist/validate-semantic.d.ts.map +1 -1
  141. package/dist/validate-semantic.js +28 -11
  142. package/dist/validate-semantic.js.map +1 -1
  143. package/examples/brainstorm.yaml +130 -0
  144. package/examples/debate.yaml +169 -0
  145. package/examples/socratic-questioning.yaml +112 -0
  146. package/package.json +5 -4
  147. package/src/__tests__/adapter-json-roundtrip.test.ts +15 -6
  148. package/src/__tests__/concurrency.test.ts +266 -0
  149. package/src/__tests__/e2e-mock-agent.test.ts +45 -7
  150. package/src/__tests__/format-text-default.test.ts +49 -0
  151. package/src/__tests__/format-text-registry.test.ts +173 -0
  152. package/src/__tests__/log-text-renderer.test.ts +294 -0
  153. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  154. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  155. package/src/__tests__/pid-recycling.test.ts +9 -8
  156. package/src/__tests__/prompt.test.ts +48 -4
  157. package/src/__tests__/resolve-head-hash.test.ts +7 -0
  158. package/src/__tests__/solve-issue-tea-worktree.test.ts +3 -1
  159. package/src/__tests__/step-ask.test.ts +8 -1
  160. package/src/__tests__/store-unified-threads.test.ts +21 -18
  161. package/src/__tests__/thread-cancel-status.test.ts +21 -14
  162. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  163. package/src/__tests__/thread-list-filters.test.ts +9 -9
  164. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  165. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  166. package/src/__tests__/thread-poke.test.ts +10 -1
  167. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  168. package/src/__tests__/thread-resume.test.ts +10 -1
  169. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  170. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  171. package/src/__tests__/thread-suspend-step.test.ts +5 -2
  172. package/src/__tests__/thread-test-helpers.ts +15 -1
  173. package/src/__tests__/thread.test.ts +10 -10
  174. package/src/__tests__/validate-semantic.test.ts +59 -2
  175. package/src/__tests__/workflow-list-recursive.test.ts +9 -9
  176. package/src/__tests__/workflow-resolution.test.ts +9 -8
  177. package/src/__tests__/workflow-show-resolution.test.ts +9 -8
  178. package/src/__tests__/workflow-validate.test.ts +78 -56
  179. package/src/__tests__/write-envelope.test.ts +257 -0
  180. package/src/cli.ts +92 -35
  181. package/src/commands/config.ts +11 -0
  182. package/src/commands/prompt.ts +42 -29
  183. package/src/commands/setup.ts +57 -7
  184. package/src/commands/thread.ts +48 -2
  185. package/src/commands/workflow.ts +3 -7
  186. package/src/concurrency/concurrency.ts +245 -0
  187. package/src/concurrency/index.ts +10 -0
  188. package/src/concurrency/types.ts +19 -0
  189. package/src/format.ts +282 -2
  190. package/src/output-mappers.ts +254 -0
  191. package/src/schemas.ts +39 -3
  192. package/src/text-renderers.ts +355 -0
  193. package/src/validate-semantic.ts +33 -12
@@ -1,23 +1,17 @@
1
- import { mkdir, mkdtemp } 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";
5
- import { describe, expect, test } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
6
  import {
7
7
  completeThread,
8
- createUwfStore,
8
+ type createUwfStore,
9
9
  getThread,
10
10
  loadActiveThreads,
11
11
  loadHistoryThreads,
12
12
  setThread,
13
13
  } from "../store.js";
14
-
15
- async function makeUwfStore(storageRoot: string) {
16
- const casDir = join(storageRoot, "cas");
17
- await mkdir(casDir, { recursive: true });
18
- process.env.OCAS_HOME = casDir;
19
- return createUwfStore(storageRoot);
20
- }
14
+ import { makeUwfStore } from "./thread-test-helpers.js";
21
15
 
22
16
  async function seedThreadHead(
23
17
  uwf: Awaited<ReturnType<typeof createUwfStore>>,
@@ -26,9 +20,25 @@ async function seedThreadHead(
26
20
  return (await uwf.store.cas.put(uwf.schemas.text, label)) as CasRef;
27
21
  }
28
22
 
23
+ let tmpDir: string;
24
+ let savedOcasHome: string | undefined;
25
+
26
+ beforeEach(async () => {
27
+ savedOcasHome = process.env.OCAS_HOME;
28
+ tmpDir = await mkdtemp(join(tmpdir(), "uwf-store-test-"));
29
+ });
30
+
31
+ afterEach(async () => {
32
+ if (savedOcasHome === undefined) {
33
+ delete process.env.OCAS_HOME;
34
+ } else {
35
+ process.env.OCAS_HOME = savedOcasHome;
36
+ }
37
+ await rm(tmpDir, { recursive: true, force: true });
38
+ });
39
+
29
40
  describe("unified thread storage", () => {
30
41
  test("loadActiveThreads excludes completed threads", async () => {
31
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-active-test-"));
32
42
  const uwf = await makeUwfStore(tmpDir);
33
43
 
34
44
  const threadId1 = "01JTEST000000000000ACTIVE1" as ThreadId;
@@ -59,7 +69,6 @@ describe("unified thread storage", () => {
59
69
  });
60
70
 
61
71
  test("loadActiveThreads excludes cancelled threads", async () => {
62
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-active-test-"));
63
72
  const uwf = await makeUwfStore(tmpDir);
64
73
 
65
74
  const threadId1 = "01JTEST000000000000ACTIVE3" as ThreadId;
@@ -90,7 +99,6 @@ describe("unified thread storage", () => {
90
99
  });
91
100
 
92
101
  test("loadHistoryThreads only returns completed and cancelled", async () => {
93
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-history-test-"));
94
102
  const uwf = await makeUwfStore(tmpDir);
95
103
 
96
104
  const threadId1 = "01JTEST000000000000HISTOR1" as ThreadId;
@@ -132,7 +140,6 @@ describe("unified thread storage", () => {
132
140
  });
133
141
 
134
142
  test("completeThread marks thread as completed", async () => {
135
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-complete-test-"));
136
143
  const uwf = await makeUwfStore(tmpDir);
137
144
  const threadId = "01JTEST000000000000COMPLE1" as ThreadId;
138
145
  const head = await seedThreadHead(uwf, "active-head");
@@ -155,7 +162,6 @@ describe("unified thread storage", () => {
155
162
  });
156
163
 
157
164
  test("completeThread marks thread as cancelled", async () => {
158
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-complete-test-"));
159
165
  const uwf = await makeUwfStore(tmpDir);
160
166
  const threadId = "01JTEST000000000000COMPLE2" as ThreadId;
161
167
  const head = await seedThreadHead(uwf, "active-head");
@@ -178,7 +184,6 @@ describe("unified thread storage", () => {
178
184
  });
179
185
 
180
186
  test("completeThread clears suspend metadata", async () => {
181
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-complete-test-"));
182
187
  const uwf = await makeUwfStore(tmpDir);
183
188
  const threadId = "01JTEST000000000000COMPLE3" as ThreadId;
184
189
  const head = await seedThreadHead(uwf, "suspended-head");
@@ -201,7 +206,6 @@ describe("unified thread storage", () => {
201
206
  });
202
207
 
203
208
  test("completeThread handles non-existent thread gracefully", async () => {
204
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-complete-test-"));
205
209
  const uwf = await makeUwfStore(tmpDir);
206
210
  const threadId = "01JTEST000000000000NOEXIST" as ThreadId;
207
211
 
@@ -213,7 +217,6 @@ describe("unified thread storage", () => {
213
217
  });
214
218
 
215
219
  test("status and completedAt tags are persisted and loaded", async () => {
216
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-tags-test-"));
217
220
  const uwf = await makeUwfStore(tmpDir);
218
221
  const threadId = "01JTEST000000000000TAGTEST" as ThreadId;
219
222
  const head = await seedThreadHead(uwf, "test-head");
@@ -1,22 +1,16 @@
1
- import { mkdir, mkdtemp } 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";
5
- import { describe, expect, test } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
6
  import {
7
7
  completeThread,
8
- createUwfStore,
8
+ type createUwfStore,
9
9
  getThread,
10
10
  loadHistoryThreads,
11
11
  setThread,
12
12
  } from "../store.js";
13
-
14
- async function makeUwfStore(storageRoot: string) {
15
- const casDir = join(storageRoot, "cas");
16
- await mkdir(casDir, { recursive: true });
17
- process.env.OCAS_HOME = casDir;
18
- return createUwfStore(storageRoot);
19
- }
13
+ import { makeUwfStore } from "./thread-test-helpers.js";
20
14
 
21
15
  async function seedHistoryHead(
22
16
  uwf: Awaited<ReturnType<typeof createUwfStore>>,
@@ -25,9 +19,25 @@ async function seedHistoryHead(
25
19
  return (await uwf.store.cas.put(uwf.schemas.text, label)) as CasRef;
26
20
  }
27
21
 
22
+ let tmpDir: string;
23
+ let savedOcasHome: string | undefined;
24
+
25
+ beforeEach(async () => {
26
+ savedOcasHome = process.env.OCAS_HOME;
27
+ tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
28
+ });
29
+
30
+ afterEach(async () => {
31
+ if (savedOcasHome === undefined) {
32
+ delete process.env.OCAS_HOME;
33
+ } else {
34
+ process.env.OCAS_HOME = savedOcasHome;
35
+ }
36
+ await rm(tmpDir, { recursive: true, force: true });
37
+ });
38
+
28
39
  describe("thread cancel status", () => {
29
40
  test("cancelled thread has status 'cancelled'", async () => {
30
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
31
41
  const threadId = "01JTEST000000000000CANCEL1" as ThreadId;
32
42
  const uwf = await makeUwfStore(tmpDir);
33
43
  const head = await seedHistoryHead(uwf, "cancelled-head");
@@ -48,7 +58,6 @@ describe("thread cancel status", () => {
48
58
  });
49
59
 
50
60
  test("completed thread has status 'completed'", async () => {
51
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
52
61
  const threadId = "01JTEST000000000000CANCEL2" as ThreadId;
53
62
  const uwf = await makeUwfStore(tmpDir);
54
63
  const head = await seedHistoryHead(uwf, "completed-head");
@@ -69,7 +78,6 @@ describe("thread cancel status", () => {
69
78
  });
70
79
 
71
80
  test("loadHistoryThreads returns completed and cancelled", async () => {
72
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
73
81
  const uwf = await makeUwfStore(tmpDir);
74
82
  const head1 = await seedHistoryHead(uwf, "head1");
75
83
  const head2 = await seedHistoryHead(uwf, "head2");
@@ -103,7 +111,6 @@ describe("thread cancel status", () => {
103
111
  });
104
112
 
105
113
  test("mixed completed and cancelled entries preserve distinct statuses", async () => {
106
- const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
107
114
  const uwf = await makeUwfStore(tmpDir);
108
115
  const head1 = await seedHistoryHead(uwf, "head1");
109
116
  const head2 = await seedHistoryHead(uwf, "head2");
@@ -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
+ });
@@ -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
+ });