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