@united-workforce/cli 0.7.0 → 0.8.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 (111) hide show
  1. package/README.md +32 -5
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/broker-step-active-turns.test.d.ts +20 -0
  4. package/dist/__tests__/broker-step-active-turns.test.d.ts.map +1 -0
  5. package/dist/__tests__/broker-step-active-turns.test.js +428 -0
  6. package/dist/__tests__/broker-step-active-turns.test.js.map +1 -0
  7. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts +13 -0
  8. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts.map +1 -0
  9. package/dist/__tests__/broker-step-turn-chain-phase2.test.js +429 -0
  10. package/dist/__tests__/broker-step-turn-chain-phase2.test.js.map +1 -0
  11. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts +18 -0
  12. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts.map +1 -0
  13. package/dist/__tests__/e2e-broker-step-suspend.test.js +313 -0
  14. package/dist/__tests__/e2e-broker-step-suspend.test.js.map +1 -0
  15. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts +28 -0
  16. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts.map +1 -0
  17. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js +322 -0
  18. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js.map +1 -0
  19. package/dist/__tests__/log-tag-validity.test.d.ts +2 -0
  20. package/dist/__tests__/log-tag-validity.test.d.ts.map +1 -0
  21. package/dist/__tests__/log-tag-validity.test.js +110 -0
  22. package/dist/__tests__/log-tag-validity.test.js.map +1 -0
  23. package/dist/__tests__/setup-agent-discovery.test.js +23 -23
  24. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  25. package/dist/__tests__/step-show-json.test.js +5 -5
  26. package/dist/__tests__/step-show-json.test.js.map +1 -1
  27. package/dist/__tests__/step-show-text.test.d.ts +2 -0
  28. package/dist/__tests__/step-show-text.test.d.ts.map +1 -0
  29. package/dist/__tests__/step-show-text.test.js +192 -0
  30. package/dist/__tests__/step-show-text.test.js.map +1 -0
  31. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts +21 -0
  32. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts.map +1 -0
  33. package/dist/__tests__/step-turns-cli-subprocess.test.js +356 -0
  34. package/dist/__tests__/step-turns-cli-subprocess.test.js.map +1 -0
  35. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts +21 -0
  36. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts.map +1 -0
  37. package/dist/__tests__/step-turns-panorama-phase3.test.js +476 -0
  38. package/dist/__tests__/step-turns-panorama-phase3.test.js.map +1 -0
  39. package/dist/__tests__/step-turns.test.d.ts +24 -0
  40. package/dist/__tests__/step-turns.test.d.ts.map +1 -0
  41. package/dist/__tests__/step-turns.test.js +646 -0
  42. package/dist/__tests__/step-turns.test.js.map +1 -0
  43. package/dist/__tests__/store-turn-chain.test.d.ts +2 -0
  44. package/dist/__tests__/store-turn-chain.test.d.ts.map +1 -0
  45. package/dist/__tests__/store-turn-chain.test.js +341 -0
  46. package/dist/__tests__/store-turn-chain.test.js.map +1 -0
  47. package/dist/__tests__/thread-list-limit-offset.test.d.ts +24 -0
  48. package/dist/__tests__/thread-list-limit-offset.test.d.ts.map +1 -0
  49. package/dist/__tests__/thread-list-limit-offset.test.js +254 -0
  50. package/dist/__tests__/thread-list-limit-offset.test.js.map +1 -0
  51. package/dist/__tests__/thread-list-template-ms-date.test.js +7 -2
  52. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -1
  53. package/dist/__tests__/thread.test.js +28 -14
  54. package/dist/__tests__/thread.test.js.map +1 -1
  55. package/dist/cli.js +910 -344
  56. package/dist/cli.js.map +1 -1
  57. package/dist/commands/broker-step.d.ts +10 -3
  58. package/dist/commands/broker-step.d.ts.map +1 -1
  59. package/dist/commands/broker-step.js +231 -27
  60. package/dist/commands/broker-step.js.map +1 -1
  61. package/dist/commands/prompt.d.ts.map +1 -1
  62. package/dist/commands/prompt.js +42 -50
  63. package/dist/commands/prompt.js.map +1 -1
  64. package/dist/commands/setup.d.ts +6 -4
  65. package/dist/commands/setup.d.ts.map +1 -1
  66. package/dist/commands/setup.js +16 -26
  67. package/dist/commands/setup.js.map +1 -1
  68. package/dist/commands/step.d.ts +48 -1
  69. package/dist/commands/step.d.ts.map +1 -1
  70. package/dist/commands/step.js +496 -3
  71. package/dist/commands/step.js.map +1 -1
  72. package/dist/output-mappers.d.ts +8 -0
  73. package/dist/output-mappers.d.ts.map +1 -1
  74. package/dist/output-mappers.js +72 -18
  75. package/dist/output-mappers.js.map +1 -1
  76. package/dist/schemas.d.ts +3 -0
  77. package/dist/schemas.d.ts.map +1 -1
  78. package/dist/schemas.js +17 -3
  79. package/dist/schemas.js.map +1 -1
  80. package/dist/store.d.ts +147 -1
  81. package/dist/store.d.ts.map +1 -1
  82. package/dist/store.js +254 -1
  83. package/dist/store.js.map +1 -1
  84. package/dist/text-renderers.d.ts.map +1 -1
  85. package/dist/text-renderers.js +27 -2
  86. package/dist/text-renderers.js.map +1 -1
  87. package/package.json +7 -6
  88. package/src/__tests__/broker-step-active-turns.test.ts +509 -0
  89. package/src/__tests__/broker-step-turn-chain-phase2.test.ts +525 -0
  90. package/src/__tests__/e2e-broker-step-suspend.test.ts +351 -0
  91. package/src/__tests__/e2e-thread-resume-timeout-suspend.test.ts +360 -0
  92. package/src/__tests__/log-tag-validity.test.ts +124 -0
  93. package/src/__tests__/setup-agent-discovery.test.ts +23 -23
  94. package/src/__tests__/step-show-json.test.ts +5 -5
  95. package/src/__tests__/step-show-text.test.ts +236 -0
  96. package/src/__tests__/step-turns-cli-subprocess.test.ts +411 -0
  97. package/src/__tests__/step-turns-panorama-phase3.test.ts +579 -0
  98. package/src/__tests__/step-turns.test.ts +734 -0
  99. package/src/__tests__/store-turn-chain.test.ts +386 -0
  100. package/src/__tests__/thread-list-limit-offset.test.ts +305 -0
  101. package/src/__tests__/thread-list-template-ms-date.test.ts +7 -2
  102. package/src/__tests__/thread.test.ts +29 -15
  103. package/src/cli.ts +1056 -483
  104. package/src/commands/broker-step.ts +315 -38
  105. package/src/commands/prompt.ts +42 -50
  106. package/src/commands/setup.ts +16 -28
  107. package/src/commands/step.ts +655 -3
  108. package/src/output-mappers.ts +99 -21
  109. package/src/schemas.ts +32 -2
  110. package/src/store.ts +297 -2
  111. package/src/text-renderers.ts +35 -2
@@ -0,0 +1,386 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CasRef, StepStartPayload, TurnNodePayload } from "@united-workforce/protocol";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { turnsOfStep, walkTurnChain, writeStepStart, writeTurnNode } from "../store.js";
7
+ import { makeUwfStore } from "./thread-test-helpers.js";
8
+
9
+ let tmpDir: string;
10
+ let savedOcasHome: string | undefined;
11
+
12
+ beforeEach(async () => {
13
+ savedOcasHome = process.env.OCAS_HOME;
14
+ tmpDir = await mkdtemp(join(tmpdir(), "uwf-turn-chain-test-"));
15
+ });
16
+
17
+ afterEach(async () => {
18
+ if (savedOcasHome === undefined) {
19
+ delete process.env.OCAS_HOME;
20
+ } else {
21
+ process.env.OCAS_HOME = savedOcasHome;
22
+ }
23
+ await rm(tmpDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("writeStepStart", () => {
27
+ test("creates step-start nodes linked via prev", async () => {
28
+ const uwf = await makeUwfStore(tmpDir);
29
+ const startRef = (await uwf.store.cas.put(uwf.schemas.text, "thread-start")) as CasRef;
30
+
31
+ // Step 0: first step (prev = null)
32
+ const step0Payload: StepStartPayload = {
33
+ role: "planner",
34
+ edgePrompt: "Analyze the issue",
35
+ stepIndex: 0,
36
+ prev: null,
37
+ start: startRef,
38
+ startedAtMs: 1000,
39
+ cwd: "/repo",
40
+ };
41
+ const ss0 = writeStepStart(uwf, step0Payload);
42
+
43
+ // Step 1: linked to step 0
44
+ const step1Payload: StepStartPayload = {
45
+ role: "developer",
46
+ edgePrompt: "Implement the fix",
47
+ stepIndex: 1,
48
+ prev: ss0,
49
+ start: startRef,
50
+ startedAtMs: 2000,
51
+ cwd: "/repo",
52
+ };
53
+ const ss1 = writeStepStart(uwf, step1Payload);
54
+
55
+ // Step 2: linked to step 1
56
+ const step2Payload: StepStartPayload = {
57
+ role: "reviewer",
58
+ edgePrompt: "Review the changes",
59
+ stepIndex: 2,
60
+ prev: ss1,
61
+ start: startRef,
62
+ startedAtMs: 3000,
63
+ cwd: "/repo",
64
+ };
65
+ const ss2 = writeStepStart(uwf, step2Payload);
66
+
67
+ // Verify hashes are distinct
68
+ expect(ss0).not.toBe(ss1);
69
+ expect(ss1).not.toBe(ss2);
70
+ expect(ss0).not.toBe(ss2);
71
+
72
+ // Verify each is 13-char Crockford Base32
73
+ expect(ss0.length).toBe(13);
74
+ expect(ss1.length).toBe(13);
75
+ expect(ss2.length).toBe(13);
76
+
77
+ // Verify nodes can be retrieved and contain exact payloads
78
+ const node0 = uwf.store.cas.get(ss0);
79
+ const node1 = uwf.store.cas.get(ss1);
80
+ const node2 = uwf.store.cas.get(ss2);
81
+
82
+ expect(node0).not.toBeNull();
83
+ expect(node1).not.toBeNull();
84
+ expect(node2).not.toBeNull();
85
+
86
+ const payload0 = node0?.payload as StepStartPayload;
87
+ const payload1 = node1?.payload as StepStartPayload;
88
+ const payload2 = node2?.payload as StepStartPayload;
89
+
90
+ expect(payload0.role).toBe("planner");
91
+ expect(payload0.stepIndex).toBe(0);
92
+ expect(payload0.prev).toBeNull();
93
+
94
+ expect(payload1.role).toBe("developer");
95
+ expect(payload1.stepIndex).toBe(1);
96
+ expect(payload1.prev).toBe(ss0);
97
+
98
+ expect(payload2.role).toBe("reviewer");
99
+ expect(payload2.stepIndex).toBe(2);
100
+ expect(payload2.prev).toBe(ss1);
101
+
102
+ // Verify walking the chain from SS2 via prev yields [SS2, SS1, SS0]
103
+ const chain: CasRef[] = [];
104
+ let currentHash: CasRef | null = ss2;
105
+ while (currentHash !== null) {
106
+ chain.push(currentHash);
107
+ const node = uwf.store.cas.get(currentHash);
108
+ if (node === null) break;
109
+ const payload = node.payload as StepStartPayload;
110
+ currentHash = payload.prev;
111
+ }
112
+ expect(chain).toEqual([ss2, ss1, ss0]);
113
+
114
+ // Verify stepIndex values in chain order
115
+ expect((uwf.store.cas.get(chain[0])?.payload as StepStartPayload).stepIndex).toBe(2);
116
+ expect((uwf.store.cas.get(chain[1])?.payload as StepStartPayload).stepIndex).toBe(1);
117
+ expect((uwf.store.cas.get(chain[2])?.payload as StepStartPayload).stepIndex).toBe(0);
118
+ });
119
+ });
120
+
121
+ describe("walkTurnChain", () => {
122
+ test("traverses turns via prev pointers in chronological order", async () => {
123
+ const uwf = await makeUwfStore(tmpDir);
124
+ const startRef = (await uwf.store.cas.put(uwf.schemas.text, "thread-start")) as CasRef;
125
+
126
+ // Create step-start nodes
127
+ const ss0 = writeStepStart(uwf, {
128
+ role: "planner",
129
+ edgePrompt: "Plan",
130
+ stepIndex: 0,
131
+ prev: null,
132
+ start: startRef,
133
+ startedAtMs: 1000,
134
+ cwd: "/repo",
135
+ });
136
+ const ss1 = writeStepStart(uwf, {
137
+ role: "developer",
138
+ edgePrompt: "Develop",
139
+ stepIndex: 1,
140
+ prev: ss0,
141
+ start: startRef,
142
+ startedAtMs: 2000,
143
+ cwd: "/repo",
144
+ });
145
+ const ss2 = writeStepStart(uwf, {
146
+ role: "reviewer",
147
+ edgePrompt: "Review",
148
+ stepIndex: 2,
149
+ prev: ss1,
150
+ start: startRef,
151
+ startedAtMs: 3000,
152
+ cwd: "/repo",
153
+ });
154
+
155
+ // Create 6 turns with prev links
156
+ const t0 = writeTurnNode(uwf, {
157
+ role: "assistant",
158
+ content: "Step 1 analysis",
159
+ prev: null,
160
+ owner: ss0,
161
+ });
162
+ const t1 = writeTurnNode(uwf, {
163
+ role: "assistant",
164
+ content: "Step 1 continued",
165
+ prev: t0,
166
+ owner: ss0,
167
+ });
168
+ const t2 = writeTurnNode(uwf, {
169
+ role: "assistant",
170
+ content: "Step 2 start",
171
+ prev: t1,
172
+ owner: ss1,
173
+ });
174
+ const t3 = writeTurnNode(uwf, {
175
+ role: "assistant",
176
+ content: "Step 2 continued",
177
+ prev: t2,
178
+ owner: ss1,
179
+ });
180
+ const t4 = writeTurnNode(uwf, {
181
+ role: "assistant",
182
+ content: "Step 3 start",
183
+ prev: t3,
184
+ owner: ss2,
185
+ });
186
+ const t5 = writeTurnNode(uwf, {
187
+ role: "assistant",
188
+ content: "Step 3 final",
189
+ prev: t4,
190
+ owner: ss2,
191
+ });
192
+
193
+ // Walk from head (t5)
194
+ const result = walkTurnChain(uwf, t5);
195
+
196
+ // Verify returns 6 hashes in chronological order (oldest first)
197
+ expect(result).toHaveLength(6);
198
+ expect(result).toEqual([t0, t1, t2, t3, t4, t5]);
199
+
200
+ // Verify content matches
201
+ const contents = result.map((h) => {
202
+ const node = uwf.store.cas.get(h);
203
+ return (node?.payload as TurnNodePayload).content;
204
+ });
205
+ expect(contents).toEqual([
206
+ "Step 1 analysis",
207
+ "Step 1 continued",
208
+ "Step 2 start",
209
+ "Step 2 continued",
210
+ "Step 3 start",
211
+ "Step 3 final",
212
+ ]);
213
+ });
214
+
215
+ test("returns single-element array for turn with null prev", async () => {
216
+ const uwf = await makeUwfStore(tmpDir);
217
+ const startRef = (await uwf.store.cas.put(uwf.schemas.text, "thread-start")) as CasRef;
218
+
219
+ const ss0 = writeStepStart(uwf, {
220
+ role: "planner",
221
+ edgePrompt: "Plan",
222
+ stepIndex: 0,
223
+ prev: null,
224
+ start: startRef,
225
+ startedAtMs: 1000,
226
+ cwd: "/repo",
227
+ });
228
+
229
+ const t0 = writeTurnNode(uwf, {
230
+ role: "assistant",
231
+ content: "Single turn",
232
+ prev: null,
233
+ owner: ss0,
234
+ });
235
+
236
+ const result = walkTurnChain(uwf, t0);
237
+ expect(result).toEqual([t0]);
238
+ });
239
+ });
240
+
241
+ describe("turnsOfStep", () => {
242
+ test("returns only turns belonging to a specific step-start", async () => {
243
+ const uwf = await makeUwfStore(tmpDir);
244
+ const startRef = (await uwf.store.cas.put(uwf.schemas.text, "thread-start")) as CasRef;
245
+
246
+ // Create step-start nodes
247
+ const ss0 = writeStepStart(uwf, {
248
+ role: "planner",
249
+ edgePrompt: "Plan",
250
+ stepIndex: 0,
251
+ prev: null,
252
+ start: startRef,
253
+ startedAtMs: 1000,
254
+ cwd: "/repo",
255
+ });
256
+ const ss1 = writeStepStart(uwf, {
257
+ role: "developer",
258
+ edgePrompt: "Develop",
259
+ stepIndex: 1,
260
+ prev: ss0,
261
+ start: startRef,
262
+ startedAtMs: 2000,
263
+ cwd: "/repo",
264
+ });
265
+ const ss2 = writeStepStart(uwf, {
266
+ role: "reviewer",
267
+ edgePrompt: "Review",
268
+ stepIndex: 2,
269
+ prev: ss1,
270
+ start: startRef,
271
+ startedAtMs: 3000,
272
+ cwd: "/repo",
273
+ });
274
+
275
+ // Create 6 turns with different owners (2 per step)
276
+ const t0 = writeTurnNode(uwf, { role: "assistant", content: "T0", prev: null, owner: ss0 });
277
+ const t1 = writeTurnNode(uwf, { role: "assistant", content: "T1", prev: t0, owner: ss0 });
278
+ const t2 = writeTurnNode(uwf, { role: "assistant", content: "T2", prev: t1, owner: ss1 });
279
+ const t3 = writeTurnNode(uwf, { role: "assistant", content: "T3", prev: t2, owner: ss1 });
280
+ const t4 = writeTurnNode(uwf, { role: "assistant", content: "T4", prev: t3, owner: ss2 });
281
+ const t5 = writeTurnNode(uwf, { role: "assistant", content: "T5", prev: t4, owner: ss2 });
282
+
283
+ // Filter for SS1's turns
284
+ const result = turnsOfStep(uwf, t5, ss1);
285
+
286
+ // Should return exactly T2 and T3 in chronological order
287
+ expect(result).toHaveLength(2);
288
+ expect(result).toEqual([t2, t3]);
289
+ });
290
+
291
+ test("returns empty array when no turns match the step", async () => {
292
+ const uwf = await makeUwfStore(tmpDir);
293
+ const startRef = (await uwf.store.cas.put(uwf.schemas.text, "thread-start")) as CasRef;
294
+
295
+ const ss0 = writeStepStart(uwf, {
296
+ role: "planner",
297
+ edgePrompt: "Plan",
298
+ stepIndex: 0,
299
+ prev: null,
300
+ start: startRef,
301
+ startedAtMs: 1000,
302
+ cwd: "/repo",
303
+ });
304
+ const ssOther = writeStepStart(uwf, {
305
+ role: "other",
306
+ edgePrompt: "Other",
307
+ stepIndex: 1,
308
+ prev: ss0,
309
+ start: startRef,
310
+ startedAtMs: 2000,
311
+ cwd: "/repo",
312
+ });
313
+
314
+ const t0 = writeTurnNode(uwf, { role: "assistant", content: "T0", prev: null, owner: ss0 });
315
+ const t1 = writeTurnNode(uwf, { role: "assistant", content: "T1", prev: t0, owner: ss0 });
316
+
317
+ // Filter for ssOther's turns (should be empty)
318
+ const result = turnsOfStep(uwf, t1, ssOther);
319
+ expect(result).toEqual([]);
320
+ });
321
+ });
322
+
323
+ describe("legacy turn compatibility", () => {
324
+ test("legacy turns without prev/owner read as null", async () => {
325
+ const uwf = await makeUwfStore(tmpDir);
326
+
327
+ // Simulate legacy turn by writing with null prev/owner
328
+ const legacyTurn = writeTurnNode(uwf, {
329
+ role: "assistant",
330
+ content: "Some output",
331
+ prev: null,
332
+ owner: null,
333
+ });
334
+
335
+ // Reading should succeed
336
+ const node = uwf.store.cas.get(legacyTurn);
337
+ expect(node).not.toBeNull();
338
+
339
+ const payload = node?.payload as TurnNodePayload;
340
+ expect(payload.prev).toBeNull();
341
+ expect(payload.owner).toBeNull();
342
+ expect(payload.role).toBe("assistant");
343
+ expect(payload.content).toBe("Some output");
344
+ });
345
+
346
+ test("walkTurnChain handles legacy turn with null prev", async () => {
347
+ const uwf = await makeUwfStore(tmpDir);
348
+
349
+ const legacyTurn = writeTurnNode(uwf, {
350
+ role: "assistant",
351
+ content: "Legacy content",
352
+ prev: null,
353
+ owner: null,
354
+ });
355
+
356
+ // Should return single-element array
357
+ const result = walkTurnChain(uwf, legacyTurn);
358
+ expect(result).toEqual([legacyTurn]);
359
+ });
360
+
361
+ test("turnsOfStep returns empty for legacy turn with null owner", async () => {
362
+ const uwf = await makeUwfStore(tmpDir);
363
+ const startRef = (await uwf.store.cas.put(uwf.schemas.text, "thread-start")) as CasRef;
364
+
365
+ const anyStepHash = writeStepStart(uwf, {
366
+ role: "planner",
367
+ edgePrompt: "Plan",
368
+ stepIndex: 0,
369
+ prev: null,
370
+ start: startRef,
371
+ startedAtMs: 1000,
372
+ cwd: "/repo",
373
+ });
374
+
375
+ const legacyTurn = writeTurnNode(uwf, {
376
+ role: "assistant",
377
+ content: "Legacy content",
378
+ prev: null,
379
+ owner: null,
380
+ });
381
+
382
+ // Legacy turn's owner is null, won't match any step
383
+ const result = turnsOfStep(uwf, legacyTurn, anyStepHash);
384
+ expect(result).toEqual([]);
385
+ });
386
+ });
@@ -0,0 +1,305 @@
1
+ /**
2
+ * #451 — `uwf thread list --limit <n> / --offset <m>` pagination.
3
+ *
4
+ * Spec: specs/thread-list-limit-offset-pagination.md
5
+ *
6
+ * `thread list` already paginates internally (cmdThreadList skip/take +
7
+ * applyPagination over the newest-first list). #451 wires the canonical
8
+ * repo-wide `ListOptions` vocabulary (`--limit`/`--offset`, as used by
9
+ * `step turns`) through to that engine, keeping `--skip`/`--take` as
10
+ * backward-compatible aliases. The behavioural gap is purely at the CLI flag
11
+ * layer: passing `--limit` today errors with `unknown option`.
12
+ *
13
+ * This file covers:
14
+ * 1. The `cmdThreadList` slice semantics that `--limit`/`--offset` map onto
15
+ * (offset → skip, limit → take) — equivalence with the existing
16
+ * `--skip`/`--take` parameters.
17
+ * 2. A CLI subprocess test (`execFileSync`) proving the new flags are
18
+ * registered and parsed end-to-end (the actual #451 regression), and that
19
+ * `--limit`/`--offset` produce the same output as `--take`/`--skip`.
20
+ *
21
+ * Follows the subprocess pattern from step-turns-cli-subprocess.test.ts.
22
+ */
23
+
24
+ import { execFileSync } from "node:child_process";
25
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
26
+ import { tmpdir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import type { CasRef, ThreadId } from "@united-workforce/protocol";
30
+ import { createThreadIndexEntry } from "@united-workforce/protocol";
31
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
32
+ import { cmdThreadList } from "../commands/thread.js";
33
+ import { createUwfStore, setThread, type UwfStore } from "../store.js";
34
+ import { makeUwfStore } from "./thread-test-helpers.js";
35
+
36
+ // ── helpers ─────────────────────────────────────────────────────────────────
37
+
38
+ async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
39
+ const workflowPayload = {
40
+ name: "test-workflow",
41
+ roles: {
42
+ role1: {
43
+ goal: "test goal",
44
+ outputSchema: { type: "object" as const, properties: {} },
45
+ },
46
+ },
47
+ graph: { start: "role1" },
48
+ conditions: {},
49
+ };
50
+ return await uwf.store.cas.put(uwf.schemas.workflow, workflowPayload);
51
+ }
52
+
53
+ /**
54
+ * Create a thread with an explicit ULID derived from `timestamp`, so the
55
+ * newest-first sort (by ULID timestamp) is deterministic.
56
+ */
57
+ async function createTestThreadAt(
58
+ uwf: UwfStore,
59
+ storageRoot: string,
60
+ workflowHash: CasRef,
61
+ ulid: string,
62
+ ): Promise<ThreadId> {
63
+ const threadId = ulid as ThreadId;
64
+ const startPayload = {
65
+ workflow: workflowHash,
66
+ prompt: "test prompt",
67
+ cwd: storageRoot,
68
+ };
69
+ const headHash = await uwf.store.cas.put(uwf.schemas.startNode, startPayload);
70
+ setThread(uwf.varStore, threadId, createThreadIndexEntry(headHash));
71
+ return threadId;
72
+ }
73
+
74
+ // ── test setup ──────────────────────────────────────────────────────────────
75
+
76
+ let tmpDir: string;
77
+ let savedOcasHome: string | undefined;
78
+
79
+ beforeEach(async () => {
80
+ savedOcasHome = process.env.OCAS_HOME;
81
+ tmpDir = await mkdtemp(join(tmpdir(), "thread-list-limit-offset-test-"));
82
+ });
83
+
84
+ afterEach(async () => {
85
+ if (savedOcasHome === undefined) {
86
+ delete process.env.OCAS_HOME;
87
+ } else {
88
+ process.env.OCAS_HOME = savedOcasHome;
89
+ }
90
+ await rm(tmpDir, { recursive: true, force: true });
91
+ });
92
+
93
+ // ── cmdThreadList: --offset → skip, --limit → take mapping ───────────────────
94
+
95
+ describe("cmdThreadList --limit/--offset mapping (#451)", () => {
96
+ /**
97
+ * Seed N threads with monotonically increasing ULID timestamps and return
98
+ * the thread IDs in newest-first order (the order `cmdThreadList` returns).
99
+ */
100
+ async function seedNewestFirst(count: number): Promise<{ newestFirst: ThreadId[] }> {
101
+ const uwf = await makeUwfStore(tmpDir);
102
+ const workflowHash = await createTestWorkflow(uwf);
103
+ const created: ThreadId[] = [];
104
+ // Distinct, strictly increasing ULID timestamps (ms) → deterministic order.
105
+ const base = Date.UTC(2026, 0, 1, 0, 0, 0);
106
+ for (let i = 0; i < count; i++) {
107
+ // generateUlid is timestamp-prefixed; use the store's helper indirectly by
108
+ // constructing via createTestThreadAt with a fabricated monotonic ULID.
109
+ const { generateUlid } = await import("@united-workforce/util");
110
+ const ulid = generateUlid(base + i * 1000);
111
+ created.push(await createTestThreadAt(uwf, tmpDir, workflowHash, ulid));
112
+ }
113
+ // Newest-first = reverse of creation order (later timestamp = newer).
114
+ return { newestFirst: [...created].reverse() };
115
+ }
116
+
117
+ test("--limit N maps to take: returns the N newest threads", async () => {
118
+ const { newestFirst } = await seedNewestFirst(12);
119
+
120
+ // --limit 5 ⇒ cmdThreadList(..., skip=null, take=5)
121
+ const result = await cmdThreadList(tmpDir, null, null, null, null, 5);
122
+
123
+ expect(result).toHaveLength(5);
124
+ expect(result.map((r) => r.thread)).toEqual(newestFirst.slice(0, 5));
125
+ });
126
+
127
+ test("--offset M maps to skip: skips the M newest threads", async () => {
128
+ const { newestFirst } = await seedNewestFirst(12);
129
+
130
+ // --offset 3 ⇒ cmdThreadList(..., skip=3, take=null)
131
+ const result = await cmdThreadList(tmpDir, null, null, null, 3, null);
132
+
133
+ expect(result).toHaveLength(9);
134
+ expect(result.map((r) => r.thread)).toEqual(newestFirst.slice(3));
135
+ });
136
+
137
+ test("--limit 5 --offset 10 ⇒ slice [10, 15) over the newest-first list", async () => {
138
+ const { newestFirst } = await seedNewestFirst(20);
139
+
140
+ // skip=10 (offset), take=5 (limit)
141
+ const result = await cmdThreadList(tmpDir, null, null, null, 10, 5);
142
+
143
+ expect(result.map((r) => r.thread)).toEqual(newestFirst.slice(10, 15));
144
+ });
145
+
146
+ test("--limit/--offset are equivalent to the legacy --take/--skip params", async () => {
147
+ await seedNewestFirst(12);
148
+
149
+ // limit==take, offset==skip → identical underlying call → identical result.
150
+ const viaLimitOffset = await cmdThreadList(tmpDir, null, null, null, 4, 3);
151
+ const viaSkipTake = await cmdThreadList(tmpDir, null, null, null, 4, 3);
152
+
153
+ expect(viaLimitOffset.map((r) => r.thread)).toEqual(viaSkipTake.map((r) => r.thread));
154
+ });
155
+
156
+ test("--offset beyond total → empty list (graceful, no error)", async () => {
157
+ await seedNewestFirst(3);
158
+
159
+ const result = await cmdThreadList(tmpDir, null, null, null, 5, null);
160
+
161
+ expect(result).toHaveLength(0);
162
+ });
163
+
164
+ test("--limit larger than remaining clamps to available range", async () => {
165
+ await seedNewestFirst(3);
166
+
167
+ const result = await cmdThreadList(tmpDir, null, null, null, null, 10);
168
+
169
+ expect(result).toHaveLength(3);
170
+ });
171
+
172
+ test("ordering invariant: contiguous non-overlapping windows", async () => {
173
+ const { newestFirst } = await seedNewestFirst(15);
174
+
175
+ // Window A: offset 0, limit 5 → [0,5)
176
+ const windowA = await cmdThreadList(tmpDir, null, null, null, 0, 5);
177
+ // Window B: offset 5, limit 5 → [5,10)
178
+ const windowB = await cmdThreadList(tmpDir, null, null, null, 5, 5);
179
+
180
+ expect(windowA.map((r) => r.thread)).toEqual(newestFirst.slice(0, 5));
181
+ expect(windowB.map((r) => r.thread)).toEqual(newestFirst.slice(5, 10));
182
+ // Non-overlapping.
183
+ const overlap = windowA
184
+ .map((r) => r.thread)
185
+ .filter((t) => windowB.map((b) => b.thread).includes(t));
186
+ expect(overlap).toHaveLength(0);
187
+ });
188
+ });
189
+
190
+ // ── CLI subprocess: --limit/--offset flag registration (#451) ────────────────
191
+
192
+ describe("uwf thread list --limit/--offset CLI subprocess (#451)", () => {
193
+ let cliTmp: string;
194
+ let storageRoot: string;
195
+ let casDir: string;
196
+ let savedUwfHome: string | undefined;
197
+ let savedOcas: string | undefined;
198
+
199
+ beforeEach(async () => {
200
+ savedUwfHome = process.env.UWF_HOME;
201
+ savedOcas = process.env.OCAS_HOME;
202
+ cliTmp = join(tmpdir(), `uwf-thread-list-limit-offset-cli-${Date.now()}`);
203
+ storageRoot = join(cliTmp, "storage");
204
+ casDir = join(cliTmp, "cas");
205
+ await mkdir(storageRoot, { recursive: true });
206
+ await mkdir(casDir, { recursive: true });
207
+ });
208
+
209
+ afterEach(async () => {
210
+ if (savedUwfHome === undefined) delete process.env.UWF_HOME;
211
+ else process.env.UWF_HOME = savedUwfHome;
212
+ if (savedOcas === undefined) delete process.env.OCAS_HOME;
213
+ else process.env.OCAS_HOME = savedOcas;
214
+ await rm(cliTmp, { recursive: true, force: true });
215
+ });
216
+
217
+ async function seedThreads(count: number): Promise<ThreadId[]> {
218
+ process.env.OCAS_HOME = casDir;
219
+ const uwf = await createUwfStore(storageRoot);
220
+ const workflowHash = await createTestWorkflow(uwf);
221
+ const { generateUlid } = await import("@united-workforce/util");
222
+ const created: ThreadId[] = [];
223
+ const base = Date.UTC(2026, 0, 1, 0, 0, 0);
224
+ for (let i = 0; i < count; i++) {
225
+ const ulid = generateUlid(base + i * 1000);
226
+ created.push(await createTestThreadAt(uwf, storageRoot, workflowHash, ulid));
227
+ }
228
+ // newest-first
229
+ return [...created].reverse();
230
+ }
231
+
232
+ function runCli(args: string[]): { threadIds: string[] } {
233
+ const pkgRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
234
+ const uwfBin = join(pkgRoot, "dist", "cli.js");
235
+ const stdout = execFileSync(process.execPath, [uwfBin, ...args], {
236
+ env: { ...process.env, UWF_HOME: storageRoot, OCAS_HOME: casDir },
237
+ encoding: "utf8",
238
+ });
239
+ const envelope = JSON.parse(stdout) as { value: { items: Array<{ threadId: string }> } };
240
+ return { threadIds: envelope.value.items.map((it) => it.threadId) };
241
+ }
242
+
243
+ test("--limit 5 is accepted (no 'unknown option') and returns 5 newest", async () => {
244
+ const newestFirst = await seedThreads(12);
245
+
246
+ const { threadIds } = runCli(["thread", "list", "--format", "json", "--limit", "5"]);
247
+
248
+ expect(threadIds).toHaveLength(5);
249
+ expect(threadIds).toEqual(newestFirst.slice(0, 5));
250
+ });
251
+
252
+ test("--limit 5 --offset 10 returns the [10,15) window of the newest-first list", async () => {
253
+ const newestFirst = await seedThreads(20);
254
+
255
+ const { threadIds } = runCli([
256
+ "thread",
257
+ "list",
258
+ "--format",
259
+ "json",
260
+ "--limit",
261
+ "5",
262
+ "--offset",
263
+ "10",
264
+ ]);
265
+
266
+ expect(threadIds).toEqual(newestFirst.slice(10, 15));
267
+ });
268
+
269
+ test("--limit/--offset produce the same result as --take/--skip aliases", async () => {
270
+ await seedThreads(12);
271
+
272
+ const canonical = runCli([
273
+ "thread",
274
+ "list",
275
+ "--format",
276
+ "json",
277
+ "--limit",
278
+ "4",
279
+ "--offset",
280
+ "3",
281
+ ]);
282
+ const legacy = runCli(["thread", "list", "--format", "json", "--take", "4", "--skip", "3"]);
283
+
284
+ expect(canonical.threadIds).toEqual(legacy.threadIds);
285
+ });
286
+
287
+ test("non-numeric --limit is a CLI usage error (non-zero exit, flag named verbatim)", async () => {
288
+ await seedThreads(3);
289
+ const pkgRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
290
+ const uwfBin = join(pkgRoot, "dist", "cli.js");
291
+
292
+ try {
293
+ execFileSync(
294
+ process.execPath,
295
+ [uwfBin, "thread", "list", "--format", "json", "--limit", "abc"],
296
+ { env: { ...process.env, UWF_HOME: storageRoot, OCAS_HOME: casDir }, encoding: "utf8" },
297
+ );
298
+ expect.fail("expected non-zero exit for non-numeric --limit");
299
+ } catch (err) {
300
+ const e = err as { status: number; stderr?: string };
301
+ expect(e.status).not.toBe(0);
302
+ expect(e.stderr ?? "").toContain("--limit");
303
+ }
304
+ });
305
+ });