mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b29607068 | |||
| 42fd1e05d9 | |||
| 8c8c1c528f | |||
| bf8b57ba12 |
+11
-413
@@ -6,7 +6,6 @@ import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import MicroagentManagement from "#/routes/microagent-management";
|
||||
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
@@ -29,12 +28,12 @@ describe("MicroagentManagement", () => {
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: null,
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
selectedMicroagentItem: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -110,7 +109,6 @@ describe("MicroagentManagement", () => {
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/test-microagent-1",
|
||||
},
|
||||
{
|
||||
name: "test-microagent-2",
|
||||
@@ -121,7 +119,6 @@ describe("MicroagentManagement", () => {
|
||||
tools: [],
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/test-microagent-2",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -171,6 +168,10 @@ describe("MicroagentManagement", () => {
|
||||
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
|
||||
...mockConversations,
|
||||
]);
|
||||
// Mock branches to always return a 'main' branch for the modal
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should render the microagent management page", async () => {
|
||||
@@ -1233,12 +1234,6 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Add microagent integration tests
|
||||
describe("Add microagent functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should render add microagent button", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -1281,16 +1276,17 @@ describe("MicroagentManagement", () => {
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: true, // Start with modal visible
|
||||
selectedRepository: {
|
||||
id: "1",
|
||||
name: "test-repo",
|
||||
full_name: "user/test-repo",
|
||||
private: false,
|
||||
git_provider: "github",
|
||||
default_branch: "main",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-01T12:00:00Z",
|
||||
},
|
||||
} as GitRepository,
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
@@ -1399,10 +1395,9 @@ describe("MicroagentManagement", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
|
||||
});
|
||||
// Enter query text
|
||||
// Wait for the confirm button to be enabled after entering query and branch selection
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
await user.type(queryInput, "Test query");
|
||||
// Wait for the confirm button to be enabled after entering query and branch selection
|
||||
await waitFor(() => {
|
||||
const confirmButton = screen.getByTestId("confirm-button");
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
@@ -1462,401 +1457,4 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// MicroagentManagementMain component tests
|
||||
describe("MicroagentManagementMain", () => {
|
||||
const mockRepositoryMicroagent: RepositoryMicroagent = {
|
||||
name: "test-microagent",
|
||||
type: "repo",
|
||||
content: "Test microagent content",
|
||||
triggers: ["test", "microagent"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/test-microagent",
|
||||
};
|
||||
|
||||
const mockConversationWithPr: Conversation = {
|
||||
conversation_id: "conv-with-pr",
|
||||
title: "Test Conversation with PR",
|
||||
selected_repository: "user/repo2/.openhands",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
trigger: "microagent_management",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
pr_number: [123],
|
||||
};
|
||||
|
||||
const mockConversationWithoutPr: Conversation = {
|
||||
conversation_id: "conv-without-pr",
|
||||
title: "Test Conversation without PR",
|
||||
selected_repository: "user/repo2/.openhands",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
trigger: "microagent_management",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
pr_number: [],
|
||||
};
|
||||
|
||||
const mockConversationWithNullPr: Conversation = {
|
||||
conversation_id: "conv-null-pr",
|
||||
title: "Test Conversation with null PR",
|
||||
selected_repository: "user/repo2/.openhands",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: null,
|
||||
trigger: "microagent_management",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
pr_number: null,
|
||||
};
|
||||
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) => {
|
||||
return renderWithProviders(<MicroagentManagementMain />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: {
|
||||
id: "1",
|
||||
full_name: "user/test-repo",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-01T12:00:00Z",
|
||||
},
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
selectedMicroagentItem,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => {
|
||||
renderMicroagentManagementMain(null);
|
||||
|
||||
// Check that the default component is rendered
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render MicroagentManagementDefault when selectedMicroagentItem is empty object", async () => {
|
||||
renderMicroagentManagementMain({});
|
||||
|
||||
// Check that the default component is rendered
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render MicroagentManagementViewMicroagent when microagent is selected", async () => {
|
||||
renderMicroagentManagementMain({
|
||||
microagent: mockRepositoryMicroagent,
|
||||
conversation: null,
|
||||
});
|
||||
|
||||
// Check that the microagent view component is rendered
|
||||
await screen.findByText("test-microagent");
|
||||
expect(
|
||||
screen.getByText(".openhands/microagents/test-microagent"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render MicroagentManagementOpeningPr when conversation is selected with empty pr_number array", async () => {
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: mockConversationWithoutPr,
|
||||
});
|
||||
|
||||
// Check that the opening PR component is rendered
|
||||
await screen.findByText(
|
||||
(content) => content === "COMMON$WORKING_ON_IT!",
|
||||
{ exact: false },
|
||||
);
|
||||
expect(
|
||||
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should render MicroagentManagementOpeningPr when conversation is selected with null pr_number", async () => {
|
||||
const conversationWithNullPr = {
|
||||
...mockConversationWithoutPr,
|
||||
pr_number: null,
|
||||
};
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: conversationWithNullPr,
|
||||
});
|
||||
|
||||
// Check that the opening PR component is rendered
|
||||
await screen.findByText(
|
||||
(content) => content === "COMMON$WORKING_ON_IT!",
|
||||
{ exact: false },
|
||||
);
|
||||
expect(
|
||||
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should render MicroagentManagementReviewPr when conversation is selected with non-empty pr_number array", async () => {
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: mockConversationWithPr,
|
||||
});
|
||||
|
||||
// Check that the review PR component is rendered
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
|
||||
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should prioritize microagent over conversation when both are present", async () => {
|
||||
renderMicroagentManagementMain({
|
||||
microagent: mockRepositoryMicroagent,
|
||||
conversation: mockConversationWithPr,
|
||||
});
|
||||
|
||||
// Should render the microagent view, not the conversation view
|
||||
await screen.findByText("test-microagent");
|
||||
expect(
|
||||
screen.getByText(".openhands/microagents/test-microagent"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should NOT render the review PR component
|
||||
expect(
|
||||
screen.queryByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle conversation with undefined pr_number", async () => {
|
||||
const conversationWithUndefinedPr = {
|
||||
...mockConversationWithoutPr,
|
||||
};
|
||||
delete conversationWithUndefinedPr.pr_number;
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: conversationWithUndefinedPr,
|
||||
});
|
||||
|
||||
// Should render the opening PR component (treats undefined as empty array)
|
||||
await screen.findByText(
|
||||
(content) => content === "COMMON$WORKING_ON_IT!",
|
||||
{ exact: false },
|
||||
);
|
||||
expect(
|
||||
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle conversation with multiple PR numbers", async () => {
|
||||
const conversationWithMultiplePrs = {
|
||||
...mockConversationWithPr,
|
||||
pr_number: [123, 456, 789],
|
||||
};
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: conversationWithMultiplePrs,
|
||||
});
|
||||
|
||||
// Should render the review PR component (non-empty array)
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
|
||||
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle conversation with empty string pr_number", async () => {
|
||||
const conversationWithEmptyStringPr = {
|
||||
...mockConversationWithoutPr,
|
||||
pr_number: "",
|
||||
};
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: conversationWithEmptyStringPr,
|
||||
});
|
||||
|
||||
// Should render the opening PR component (treats empty string as empty array)
|
||||
await screen.findByText(
|
||||
(content) => content === "COMMON$WORKING_ON_IT!",
|
||||
{ exact: false },
|
||||
);
|
||||
expect(
|
||||
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle conversation with zero pr_number", async () => {
|
||||
const conversationWithZeroPr = {
|
||||
...mockConversationWithoutPr,
|
||||
pr_number: 0,
|
||||
};
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: conversationWithZeroPr,
|
||||
});
|
||||
|
||||
// Should render the opening PR component (treats 0 as falsy)
|
||||
await screen.findByText(
|
||||
(content) => content === "COMMON$WORKING_ON_IT!",
|
||||
{ exact: false },
|
||||
);
|
||||
expect(
|
||||
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle conversation with single PR number as array", async () => {
|
||||
const conversationWithSinglePr = {
|
||||
...mockConversationWithPr,
|
||||
pr_number: [42],
|
||||
};
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: conversationWithSinglePr,
|
||||
});
|
||||
|
||||
// Should render the review PR component (non-empty array)
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
|
||||
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle edge case with null selectedMicroagentItem", async () => {
|
||||
renderMicroagentManagementMain(null);
|
||||
|
||||
// Should render the default component
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle edge case with undefined selectedMicroagentItem", async () => {
|
||||
renderMicroagentManagementMain(undefined);
|
||||
|
||||
// Should render the default component
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle conversation with missing pr_number property", async () => {
|
||||
const conversationWithoutPrNumber = {
|
||||
conversation_id: "conv-no-pr-number",
|
||||
title: "Test Conversation without PR number property",
|
||||
selected_repository: "user/repo2/.openhands",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
trigger: "microagent_management",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
// pr_number property is missing
|
||||
};
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: conversationWithoutPrNumber,
|
||||
});
|
||||
|
||||
// Should render the opening PR component (undefined pr_number defaults to empty array)
|
||||
await screen.findByText(
|
||||
(content) => content === "COMMON$WORKING_ON_IT!",
|
||||
{ exact: false },
|
||||
);
|
||||
expect(
|
||||
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle microagent with all required properties", async () => {
|
||||
const completeMicroagent: RepositoryMicroagent = {
|
||||
name: "complete-microagent",
|
||||
type: "knowledge",
|
||||
content: "Complete microagent content with all properties",
|
||||
triggers: ["complete", "test"],
|
||||
inputs: ["input1", "input2"],
|
||||
tools: ["tool1", "tool2"],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/complete-microagent",
|
||||
};
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: completeMicroagent,
|
||||
conversation: null,
|
||||
});
|
||||
|
||||
// Check that the microagent view component is rendered with complete data
|
||||
await screen.findByText("complete-microagent");
|
||||
expect(
|
||||
screen.getByText(".openhands/microagents/complete-microagent"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle conversation with all required properties", async () => {
|
||||
const completeConversation: Conversation = {
|
||||
conversation_id: "complete-conversation",
|
||||
title: "Complete Conversation",
|
||||
selected_repository: "user/complete-repo/.openhands",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
trigger: "microagent_management",
|
||||
url: "https://example.com",
|
||||
session_api_key: "test-api-key",
|
||||
pr_number: [999],
|
||||
};
|
||||
|
||||
renderMicroagentManagementMain({
|
||||
microagent: null,
|
||||
conversation: completeConversation,
|
||||
});
|
||||
|
||||
// Check that the review PR component is rendered with complete data
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
|
||||
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+3
-40
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
|
||||
import { MicroagentManagementMain } from "./microagent-management-main";
|
||||
@@ -73,29 +73,14 @@ ${formData.query}
|
||||
`;
|
||||
|
||||
export function MicroagentManagementContent() {
|
||||
// Responsive width state
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
|
||||
const { addMicroagentModalVisible, selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
useCreateConversationAndSubscribeMultiple();
|
||||
|
||||
function handleResize() {
|
||||
setWidth(window.innerWidth);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideAddMicroagentModal = () => {
|
||||
dispatch(setAddMicroagentModalVisible(false));
|
||||
};
|
||||
@@ -192,32 +177,10 @@ export function MicroagentManagementContent() {
|
||||
});
|
||||
};
|
||||
|
||||
if (width < 1024) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-6">
|
||||
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
|
||||
<MicroagentManagementSidebar isSmallerScreen />
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
|
||||
<MicroagentManagementMain />
|
||||
</div>
|
||||
{addMicroagentModalVisible && (
|
||||
<MicroagentManagementAddMicroagentModal
|
||||
onConfirm={handleCreateMicroagent}
|
||||
onCancel={hideAddMicroagentModal}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
|
||||
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
|
||||
<MicroagentManagementSidebar />
|
||||
<div className="flex-1">
|
||||
<MicroagentManagementMain />
|
||||
</div>
|
||||
<MicroagentManagementMain />
|
||||
{addMicroagentModalVisible && (
|
||||
<MicroagentManagementAddMicroagentModal
|
||||
onConfirm={handleCreateMicroagent}
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { Loader } from "#/components/shared/loader";
|
||||
|
||||
export function MicroagentManagementConversationStopped() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { conversation } = selectedMicroagentItem ?? {};
|
||||
|
||||
const { conversation_id: conversationId } = conversation ?? {};
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full items-center justify-center">
|
||||
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)}
|
||||
</div>
|
||||
<Loader size="small" className="pb-[22px]" />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="view-conversation-button"
|
||||
>
|
||||
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function MicroagentManagementDefault() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full items-center justify-center">
|
||||
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
|
||||
</div>
|
||||
<div className="text-white text-sm font-normal text-center max-w-[455px]">
|
||||
{t(
|
||||
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { Loader } from "#/components/shared/loader";
|
||||
|
||||
export function MicroagentManagementError() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { conversation } = selectedMicroagentItem ?? {};
|
||||
|
||||
const { conversation_id: conversationId } = conversation ?? {};
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full items-center justify-center">
|
||||
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)}
|
||||
</div>
|
||||
<Loader size="small" className="pb-[22px]" />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="view-conversation-button"
|
||||
>
|
||||
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+19
-42
@@ -1,52 +1,29 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RootState } from "#/store";
|
||||
import { MicroagentManagementDefault } from "./microagent-management-default";
|
||||
import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr";
|
||||
import { MicroagentManagementReviewPr } from "./microagent-management-review-pr";
|
||||
import { MicroagentManagementViewMicroagent } from "./microagent-management-view-microagent";
|
||||
import { MicroagentManagementError } from "./microagent-management-error";
|
||||
import { MicroagentManagementConversationStopped } from "./microagent-management-conversation-stopped";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function MicroagentManagementMain() {
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedMicroagent } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { microagent, conversation } = selectedMicroagentItem ?? {};
|
||||
|
||||
if (microagent) {
|
||||
return <MicroagentManagementViewMicroagent />;
|
||||
if (!selectedMicroagent) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full items-center justify-center">
|
||||
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
|
||||
</div>
|
||||
<div className="text-white text-sm font-normal text-center max-w-[455px]">
|
||||
{t(
|
||||
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversation) {
|
||||
if (conversation.pr_number && conversation.pr_number.length > 0) {
|
||||
return <MicroagentManagementReviewPr />;
|
||||
}
|
||||
|
||||
const isConversationStarting =
|
||||
conversation.status === "STARTING" ||
|
||||
conversation.runtime_status === "STATUS$STARTING_RUNTIME";
|
||||
const isConversationOpeningPr =
|
||||
conversation.status === "RUNNING" &&
|
||||
conversation.runtime_status === "STATUS$READY";
|
||||
|
||||
if (isConversationStarting || isConversationOpeningPr) {
|
||||
return <MicroagentManagementOpeningPr />;
|
||||
}
|
||||
|
||||
if (conversation.runtime_status === "STATUS$ERROR") {
|
||||
return <MicroagentManagementError />;
|
||||
}
|
||||
|
||||
if (
|
||||
conversation.status === "STOPPED" ||
|
||||
conversation.runtime_status === "STATUS$STOPPED"
|
||||
) {
|
||||
return <MicroagentManagementConversationStopped />;
|
||||
}
|
||||
|
||||
return <MicroagentManagementDefault />;
|
||||
}
|
||||
|
||||
return <MicroagentManagementDefault />;
|
||||
return null;
|
||||
}
|
||||
|
||||
+25
-87
@@ -1,72 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import {
|
||||
setSelectedMicroagentItem,
|
||||
setSelectedRepository,
|
||||
} from "#/state/microagent-management-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { RuntimeStatus } from "#/types/runtime-status";
|
||||
|
||||
interface MicroagentManagementMicroagentCardProps {
|
||||
microagent?: RepositoryMicroagent;
|
||||
conversation?: Conversation;
|
||||
repository: GitRepository;
|
||||
microagent: {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
conversationStatus?: ConversationStatus;
|
||||
runtimeStatus?: RuntimeStatus;
|
||||
prNumber?: number[] | null;
|
||||
};
|
||||
showMicroagentFilePath?: boolean;
|
||||
}
|
||||
|
||||
export function MicroagentManagementMicroagentCard({
|
||||
microagent,
|
||||
conversation,
|
||||
repository,
|
||||
showMicroagentFilePath = true,
|
||||
}: MicroagentManagementMicroagentCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
status: conversationStatus,
|
||||
runtime_status: runtimeStatus,
|
||||
pr_number: prNumber,
|
||||
} = conversation ?? {};
|
||||
const { conversationStatus, runtimeStatus, prNumber } = microagent;
|
||||
|
||||
// Format the repository URL to point to the microagent file
|
||||
const microagentFilePath = microagent
|
||||
? `.openhands/microagents/${microagent.name}`
|
||||
: "";
|
||||
const microagentFilePath = `.openhands/microagents/${microagent.name}`;
|
||||
|
||||
// Format the createdAt date using MM/DD/YYYY format
|
||||
const formattedCreatedAt = useMemo(() => {
|
||||
if (microagent) {
|
||||
return formatDateMMDDYYYY(new Date(microagent.created_at));
|
||||
}
|
||||
if (conversation) {
|
||||
return formatDateMMDDYYYY(new Date(conversation.created_at));
|
||||
}
|
||||
return "";
|
||||
}, [microagent, conversation]);
|
||||
const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt));
|
||||
|
||||
const hasPr = !!(prNumber && prNumber.length > 0);
|
||||
const hasPr = prNumber && prNumber.length > 0;
|
||||
|
||||
// Helper function to get status text
|
||||
const statusText = useMemo(() => {
|
||||
if (hasPr) {
|
||||
return t(I18nKey.COMMON$READY_FOR_REVIEW);
|
||||
}
|
||||
if (
|
||||
conversationStatus === "STARTING" ||
|
||||
runtimeStatus === "STATUS$STARTING_RUNTIME"
|
||||
) {
|
||||
return t(I18nKey.COMMON$STARTING);
|
||||
}
|
||||
if (
|
||||
conversationStatus === "STOPPED" ||
|
||||
runtimeStatus === "STATUS$STOPPED"
|
||||
@@ -76,60 +47,27 @@ export function MicroagentManagementMicroagentCard({
|
||||
if (runtimeStatus === "STATUS$ERROR") {
|
||||
return t(I18nKey.MICROAGENT$STATUS_ERROR);
|
||||
}
|
||||
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
|
||||
if (
|
||||
(conversationStatus === "STARTING" || conversationStatus === "RUNNING") &&
|
||||
runtimeStatus === "STATUS$READY"
|
||||
) {
|
||||
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
|
||||
}
|
||||
return "";
|
||||
}, [conversationStatus, runtimeStatus, t, hasPr]);
|
||||
|
||||
const cardTitle = microagent?.name ?? conversation?.title;
|
||||
|
||||
const isCardSelected = useMemo(() => {
|
||||
if (microagent && selectedMicroagentItem?.microagent) {
|
||||
return selectedMicroagentItem.microagent.name === microagent.name;
|
||||
}
|
||||
if (conversation && selectedMicroagentItem?.conversation) {
|
||||
return (
|
||||
selectedMicroagentItem.conversation.conversation_id ===
|
||||
conversation.conversation_id
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}, [microagent, conversation, selectedMicroagentItem]);
|
||||
|
||||
const onMicroagentCardClicked = () => {
|
||||
dispatch(
|
||||
setSelectedMicroagentItem(
|
||||
microagent
|
||||
? {
|
||||
microagent,
|
||||
conversation: null,
|
||||
}
|
||||
: {
|
||||
microagent: null,
|
||||
conversation,
|
||||
},
|
||||
),
|
||||
);
|
||||
dispatch(setSelectedRepository(repository));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300",
|
||||
isCardSelected && "bg-[#ffffff33] border-[#C9B974]",
|
||||
)}
|
||||
onClick={onMicroagentCardClicked}
|
||||
>
|
||||
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{statusText && (
|
||||
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
|
||||
{!!microagent && (
|
||||
<div className="text-white text-[16px] font-semibold">
|
||||
{microagent.name}
|
||||
</div>
|
||||
{showMicroagentFilePath && (
|
||||
<div className="text-white text-sm font-normal">
|
||||
{microagentFilePath}
|
||||
</div>
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { Loader } from "#/components/shared/loader";
|
||||
|
||||
export function MicroagentManagementOpeningPr() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { conversation } = selectedMicroagentItem ?? {};
|
||||
|
||||
const { conversation_id: conversationId } = conversation ?? {};
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full items-center justify-center">
|
||||
<div className="text-[#ffffff99] text-[22px] font-semibold pb-2">
|
||||
{t(I18nKey.COMMON$WORKING_ON_IT)}!
|
||||
</div>
|
||||
<div className="text-[#ffffff99] text-[18px] font-normal text-center max-w-[518px] pb-[22px]">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT)}
|
||||
</div>
|
||||
<Loader size="small" className="pb-[22px]" />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="view-conversation-button"
|
||||
>
|
||||
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
-47
@@ -1,34 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
|
||||
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
|
||||
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { getGitProviderBaseUrl } from "#/utils/utils";
|
||||
import { RootState } from "#/store";
|
||||
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
|
||||
|
||||
export interface RepoMicroagent {
|
||||
id: string;
|
||||
repositoryName: string;
|
||||
repositoryUrl: string;
|
||||
}
|
||||
|
||||
interface MicroagentManagementRepoMicroagentsProps {
|
||||
repository: GitRepository;
|
||||
repoMicroagent: RepoMicroagent;
|
||||
}
|
||||
|
||||
export function MicroagentManagementRepoMicroagents({
|
||||
repository,
|
||||
repoMicroagent,
|
||||
}: MicroagentManagementRepoMicroagentsProps) {
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { full_name: repositoryName, git_provider: gitProvider } = repository;
|
||||
|
||||
// Extract owner and repo from repositoryName (format: "owner/repo")
|
||||
const [owner, repo] = repositoryName.split("/");
|
||||
|
||||
const repositoryUrl = `${getGitProviderBaseUrl(gitProvider)}/${repositoryName}`;
|
||||
const [owner, repo] = repoMicroagent.repositoryName.split("/");
|
||||
|
||||
const {
|
||||
data: microagents,
|
||||
@@ -40,28 +30,11 @@ export function MicroagentManagementRepoMicroagents({
|
||||
data: conversations,
|
||||
isLoading: isLoadingConversations,
|
||||
isError: isErrorConversations,
|
||||
} = useSearchConversations(repositoryName, "microagent_management", 1000);
|
||||
|
||||
useEffect(() => {
|
||||
const hasConversations = conversations && conversations.length > 0;
|
||||
const selectedConversation = selectedMicroagentItem?.conversation;
|
||||
|
||||
if (hasConversations && selectedConversation) {
|
||||
// get the latest selected conversation.
|
||||
const latestSelectedConversation = conversations.find(
|
||||
(conversation) =>
|
||||
conversation.conversation_id === selectedConversation.conversation_id,
|
||||
);
|
||||
if (latestSelectedConversation) {
|
||||
dispatch(
|
||||
setSelectedMicroagentItem({
|
||||
microagent: null,
|
||||
conversation: latestSelectedConversation,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [conversations]);
|
||||
} = useSearchConversations(
|
||||
repoMicroagent.repositoryName,
|
||||
"microagent_management",
|
||||
1000,
|
||||
);
|
||||
|
||||
// Show loading only when both queries are loading
|
||||
const isLoading = isLoadingMicroagents || isLoadingConversations;
|
||||
@@ -81,7 +54,9 @@ export function MicroagentManagementRepoMicroagents({
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
|
||||
<MicroagentManagementLearnThisRepo
|
||||
repositoryUrl={repoMicroagent.repositoryUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +68,9 @@ export function MicroagentManagementRepoMicroagents({
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{totalItems === 0 && (
|
||||
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
|
||||
<MicroagentManagementLearnThisRepo
|
||||
repositoryUrl={repoMicroagent.repositoryUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Render microagents */}
|
||||
@@ -101,8 +78,11 @@ export function MicroagentManagementRepoMicroagents({
|
||||
microagents?.map((microagent) => (
|
||||
<div key={microagent.name} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard
|
||||
microagent={microagent}
|
||||
repository={repository}
|
||||
microagent={{
|
||||
id: microagent.name,
|
||||
name: microagent.name,
|
||||
createdAt: microagent.created_at,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -112,8 +92,15 @@ export function MicroagentManagementRepoMicroagents({
|
||||
conversations?.map((conversation) => (
|
||||
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard
|
||||
conversation={conversation}
|
||||
repository={repository}
|
||||
microagent={{
|
||||
id: conversation.conversation_id,
|
||||
name: conversation.title,
|
||||
createdAt: conversation.created_at,
|
||||
conversationStatus: conversation.status,
|
||||
runtimeStatus: conversation.runtime_status || undefined,
|
||||
prNumber: conversation.pr_number || undefined,
|
||||
}}
|
||||
showMicroagentFilePath={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+8
-2
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Accordion, AccordionItem } from "@heroui/react";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { getGitProviderBaseUrl, cn } from "#/utils/utils";
|
||||
import { TabType } from "#/types/microagent-management";
|
||||
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
@@ -110,7 +110,13 @@ export function MicroagentManagementRepositories({
|
||||
<MicroagentManagementAccordionTitle repository={repository} />
|
||||
}
|
||||
>
|
||||
<MicroagentManagementRepoMicroagents repository={repository} />
|
||||
<MicroagentManagementRepoMicroagents
|
||||
repoMicroagent={{
|
||||
id: repository.id,
|
||||
repositoryName: repository.full_name,
|
||||
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
|
||||
}}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { getProviderName, constructPullRequestUrl } from "#/utils/utils";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export function MicroagentManagementReviewPr() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { conversation } = selectedMicroagentItem ?? {};
|
||||
|
||||
const {
|
||||
conversation_id: conversationId,
|
||||
selected_repository: selectedRepository,
|
||||
git_provider: gitProvider,
|
||||
pr_number: prNumber,
|
||||
} = conversation ?? {};
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full items-center justify-center">
|
||||
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY)}
|
||||
</div>
|
||||
<div className="flex gap-[22px]">
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="view-conversation-button"
|
||||
>
|
||||
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
<a
|
||||
href={
|
||||
selectedRepository && gitProvider && prNumber && prNumber.length > 0
|
||||
? constructPullRequestUrl(
|
||||
prNumber[0],
|
||||
gitProvider,
|
||||
selectedRepository,
|
||||
)
|
||||
: "/#"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
testId="view-conversation-button"
|
||||
>
|
||||
{`${t(I18nKey.COMMON$REVIEW_PR_IN)} ${getProviderName(
|
||||
gitProvider as Provider,
|
||||
)}`}
|
||||
</BrandButton>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-8
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import QuestionCircleIcon from "#/icons/question-circle.svg?react";
|
||||
import { DOCUMENTATION_URL } from "#/utils/constants";
|
||||
|
||||
export function MicroagentManagementSidebarHeader() {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,13 +12,7 @@ export function MicroagentManagementSidebarHeader() {
|
||||
</h1>
|
||||
<p className="text-white text-sm font-normal leading-[20px] pt-2">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)}
|
||||
<a
|
||||
href={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<QuestionCircleIcon className="inline-block ml-1" />
|
||||
</a>
|
||||
<QuestionCircleIcon className="inline-block ml-1" />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
-14
@@ -11,15 +11,8 @@ import {
|
||||
} from "#/state/microagent-management-slice";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MicroagentManagementSidebarProps {
|
||||
isSmallerScreen?: boolean;
|
||||
}
|
||||
|
||||
export function MicroagentManagementSidebar({
|
||||
isSmallerScreen = false,
|
||||
}: MicroagentManagementSidebarProps) {
|
||||
export function MicroagentManagementSidebar() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { data: repositories, isLoading } = useUserRepositories();
|
||||
@@ -49,12 +42,7 @@ export function MicroagentManagementSidebar({
|
||||
}, [repositories, dispatch]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
|
||||
isSmallerScreen && "w-full border-none",
|
||||
)}
|
||||
>
|
||||
<div className="w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col">
|
||||
<MicroagentManagementSidebarHeader />
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 flex-1">
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import { code } from "../markdown/code";
|
||||
import { ul, ol } from "../markdown/list";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
import { anchor } from "../markdown/anchor";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export function MicroagentManagementViewMicroagentContent() {
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { microagent } = selectedMicroagentItem ?? {};
|
||||
|
||||
const transformMicroagentContent = (): string => {
|
||||
if (!microagent) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// If no triggers exist, return the content as-is
|
||||
if (!microagent.triggers || microagent.triggers.length === 0) {
|
||||
return microagent.content;
|
||||
}
|
||||
|
||||
// Create the triggers frontmatter
|
||||
const triggersFrontmatter = `
|
||||
---
|
||||
|
||||
triggers:
|
||||
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
// Prepend the frontmatter to the content
|
||||
return `
|
||||
${triggersFrontmatter}
|
||||
|
||||
${microagent.content}
|
||||
`;
|
||||
};
|
||||
|
||||
if (!microagent || !selectedRepository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform the content to include triggers frontmatter if applicable
|
||||
const transformedContent = transformMicroagentContent();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{transformedContent}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RootState } from "#/store";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { getProviderName, constructMicroagentUrl } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function MicroagentManagementViewMicroagentHeader() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { microagent } = selectedMicroagentItem ?? {};
|
||||
|
||||
if (!microagent || !selectedRepository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Construct the microagent URL
|
||||
const microagentUrl = constructMicroagentUrl(
|
||||
selectedRepository.git_provider,
|
||||
selectedRepository.full_name,
|
||||
microagent.path,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<span className="text-sm text-[#ffffff99]">
|
||||
{selectedRepository.full_name}
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<a href={microagentUrl} target="_blank" rel="noopener noreferrer">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="edit-in-git-button"
|
||||
className="py-1 px-2"
|
||||
>
|
||||
{`${t(I18nKey.COMMON$EDIT_IN)} ${getProviderName(selectedRepository.git_provider)}`}
|
||||
</BrandButton>
|
||||
</a>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {}}
|
||||
testId="learn-button"
|
||||
className="py-1 px-2"
|
||||
>
|
||||
{t(I18nKey.COMMON$LEARN)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { MicroagentManagementViewMicroagentHeader } from "./microagent-management-view-microagent-header";
|
||||
import { MicroagentManagementViewMicroagentContent } from "./microagent-management-view-microagent-content";
|
||||
|
||||
export function MicroagentManagementViewMicroagent() {
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const { microagent } = selectedMicroagentItem ?? {};
|
||||
|
||||
if (!microagent || !selectedRepository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full p-6 overflow-auto">
|
||||
<MicroagentManagementViewMicroagentHeader />
|
||||
<span className="text-white text-2xl font-medium pb-2">
|
||||
{microagent.name}
|
||||
</span>
|
||||
<span className="text-white text-lg font-medium pb-6">
|
||||
{microagent.path}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<MicroagentManagementViewMicroagentContent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface LoaderProps {
|
||||
size?: "small" | "medium" | "large";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Loader({ size = "medium", className }: LoaderProps) {
|
||||
const sizeClasses = {
|
||||
small: "w-3 h-3",
|
||||
medium: "w-4 h-4",
|
||||
large: "w-5 h-5",
|
||||
};
|
||||
|
||||
const dotSize = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="loader"
|
||||
className={cn("flex items-center justify-center", className)}
|
||||
>
|
||||
<div className={cn("loader rounded-full", dotSize)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// foo.ts - Empty TypeScript file
|
||||
@@ -717,13 +717,4 @@ export enum I18nKey {
|
||||
COMMON$COMPLETED = "COMMON$COMPLETED",
|
||||
COMMON$COMPLETED_PARTIALLY = "COMMON$COMPLETED_PARTIALLY",
|
||||
COMMON$STOPPED = "COMMON$STOPPED",
|
||||
COMMON$WORKING_ON_IT = "COMMON$WORKING_ON_IT",
|
||||
MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT = "MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT",
|
||||
MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY = "MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY",
|
||||
COMMON$REVIEW_PR_IN = "COMMON$REVIEW_PR_IN",
|
||||
COMMON$EDIT_IN = "COMMON$EDIT_IN",
|
||||
COMMON$LEARN = "COMMON$LEARN",
|
||||
COMMON$STARTING = "COMMON$STARTING",
|
||||
MICROAGENT_MANAGEMENT$ERROR = "MICROAGENT_MANAGEMENT$ERROR",
|
||||
MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED = "MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED",
|
||||
}
|
||||
|
||||
@@ -11470,149 +11470,5 @@
|
||||
"tr": "Durduruldu",
|
||||
"de": "Gestoppt",
|
||||
"uk": "Зупинено"
|
||||
},
|
||||
"COMMON$WORKING_ON_IT": {
|
||||
"en": "Working on it",
|
||||
"ja": "作業中",
|
||||
"zh-CN": "正在处理",
|
||||
"zh-TW": "正在處理",
|
||||
"ko-KR": "작업 중",
|
||||
"no": "Jobber med det",
|
||||
"it": "Ci sto lavorando",
|
||||
"pt": "Trabalhando nisso",
|
||||
"es": "Trabajando en ello",
|
||||
"ar": "يتم العمل عليه",
|
||||
"fr": "En cours",
|
||||
"tr": "Üzerinde çalışılıyor",
|
||||
"de": "Wird bearbeitet",
|
||||
"uk": "В процесі виконання"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT": {
|
||||
"en": "We're working on it! Once OpenHands is done investigating, you'll be able to review its pull request before merging your new microagent.",
|
||||
"ja": "作業中です!OpenHandsの調査が完了すると、新しいマイクロエージェントをマージする前にプルリクエストを確認できます。",
|
||||
"zh-CN": "我们正在处理!OpenHands 调查完成后,您将能够在合并新微代理之前审查其拉取请求。",
|
||||
"zh-TW": "我們正在處理!OpenHands 調查完成後,您將能在合併新微代理前審查其拉取請求。",
|
||||
"ko-KR": "작업 중입니다! OpenHands의 조사가 끝나면 새 마이크로에이전트를 병합하기 전에 풀 리퀘스트를 검토할 수 있습니다.",
|
||||
"no": "Vi jobber med det! Når OpenHands er ferdig med å undersøke, kan du gjennomgå pull requesten før du slår sammen din nye mikroagent.",
|
||||
"it": "Ci stiamo lavorando! Una volta che OpenHands avrà terminato l'analisi, potrai rivedere la pull request prima di unire il tuo nuovo microagent.",
|
||||
"pt": "Estamos trabalhando nisso! Assim que o OpenHands terminar a investigação, você poderá revisar o pull request antes de mesclar seu novo microagente.",
|
||||
"es": "¡Estamos trabajando en ello! Una vez que OpenHands termine de investigar, podrás revisar su pull request antes de fusionar tu nuevo microagente.",
|
||||
"ar": "نحن نعمل على ذلك! بمجرد أن ينتهي OpenHands من التحقيق، ستتمكن من مراجعة طلب السحب قبل دمج وكيلك الدقيق الجديد.",
|
||||
"fr": "Nous y travaillons ! Une fois qu'OpenHands aura terminé l'investigation, vous pourrez examiner sa pull request avant de fusionner votre nouveau microagent.",
|
||||
"tr": "Üzerinde çalışıyoruz! OpenHands incelemeyi bitirdiğinde, yeni mikro ajanınızı birleştirmeden önce pull request'i gözden geçirebileceksiniz.",
|
||||
"de": "Wir arbeiten daran! Sobald OpenHands die Untersuchung abgeschlossen hat, können Sie den Pull Request überprüfen, bevor Sie Ihren neuen Microagenten zusammenführen.",
|
||||
"uk": "Ми працюємо над цим! Після завершення розслідування OpenHands ви зможете переглянути його pull request перед об'єднанням нового мікроагента."
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY": {
|
||||
"en": "Your microagent is ready! Merge the PR in GitHub to start using it.",
|
||||
"ja": "マイクロエージェントの準備ができました!GitHubでPRをマージして使い始めましょう。",
|
||||
"zh-CN": "您的微代理已准备就绪!在 GitHub 上合并 PR 即可开始使用。",
|
||||
"zh-TW": "您的微代理已準備就緒!在 GitHub 上合併 PR 即可開始使用。",
|
||||
"ko-KR": "마이크로에이전트가 준비되었습니다! GitHub에서 PR을 병합하여 사용을 시작하세요.",
|
||||
"no": "Din mikroagent er klar! Slå sammen PR-en i GitHub for å begynne å bruke den.",
|
||||
"it": "Il tuo microagente è pronto! Unisci la PR su GitHub per iniziare a usarlo.",
|
||||
"pt": "Seu microagente está pronto! Faça o merge do PR no GitHub para começar a usá-lo.",
|
||||
"es": "¡Tu microagente está listo! Haz merge del PR en GitHub para empezar a usarlo.",
|
||||
"ar": "وكيلك المصغر جاهز! ادمج طلب السحب في GitHub لبدء استخدامه.",
|
||||
"fr": "Votre micro-agent est prêt ! Fusionnez la PR sur GitHub pour commencer à l'utiliser.",
|
||||
"tr": "Mikro ajanınız hazır! Kullanmak için GitHub'da PR'ı birleştirin.",
|
||||
"de": "Ihr Microagent ist bereit! Führen Sie den PR in GitHub zusammen, um ihn zu verwenden.",
|
||||
"uk": "Ваш мікроагент готовий! Злийте PR у GitHub, щоб почати ним користуватися."
|
||||
},
|
||||
"COMMON$REVIEW_PR_IN": {
|
||||
"en": "Review PR in",
|
||||
"ja": "でPRをレビュー",
|
||||
"zh-CN": "在中审查PR",
|
||||
"zh-TW": "在中審查PR",
|
||||
"ko-KR": "에서 PR 검토",
|
||||
"no": "Se gjennom PR i",
|
||||
"it": "Revisiona la PR su",
|
||||
"pt": "Revisar PR em",
|
||||
"es": "Revisar PR en",
|
||||
"ar": "مراجعة PR في",
|
||||
"fr": "Examiner la PR sur",
|
||||
"tr": "PR'ı şurada gözden geçir:",
|
||||
"de": "PR überprüfen in",
|
||||
"uk": "Переглянути PR у"
|
||||
},
|
||||
"COMMON$EDIT_IN": {
|
||||
"en": "Edit in",
|
||||
"ja": "で編集",
|
||||
"zh-CN": "在中编辑",
|
||||
"zh-TW": "在中編輯",
|
||||
"ko-KR": "에서 편집",
|
||||
"no": "Rediger i",
|
||||
"it": "Modifica su",
|
||||
"pt": "Editar em",
|
||||
"es": "Editar en",
|
||||
"ar": "تعديل في",
|
||||
"fr": "Modifier dans",
|
||||
"tr": "Şurada düzenle:",
|
||||
"de": "Bearbeiten in",
|
||||
"uk": "Редагувати у"
|
||||
},
|
||||
"COMMON$LEARN": {
|
||||
"en": "Learn",
|
||||
"ja": "学ぶ",
|
||||
"zh-CN": "学习",
|
||||
"zh-TW": "學習",
|
||||
"ko-KR": "학습",
|
||||
"no": "Lær",
|
||||
"it": "Impara",
|
||||
"pt": "Aprender",
|
||||
"es": "Aprender",
|
||||
"ar": "تعلم",
|
||||
"fr": "Apprendre",
|
||||
"tr": "Öğren",
|
||||
"de": "Lernen",
|
||||
"uk": "Вчитися"
|
||||
},
|
||||
"COMMON$STARTING": {
|
||||
"en": "Starting",
|
||||
"ja": "開始中",
|
||||
"zh-CN": "启动中",
|
||||
"zh-TW": "啟動中",
|
||||
"ko-KR": "시작 중",
|
||||
"no": "Starter",
|
||||
"it": "Avvio",
|
||||
"pt": "Iniciando",
|
||||
"es": "Iniciando",
|
||||
"ar": "جارٍ البدء",
|
||||
"fr": "Démarrage",
|
||||
"tr": "Başlatılıyor",
|
||||
"de": "Wird gestartet",
|
||||
"uk": "Запуск"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$ERROR": {
|
||||
"en": "The system has encountered an error. Please try again later.",
|
||||
"ja": "システムでエラーが発生しました。後でもう一度お試しください。",
|
||||
"zh-CN": "系统遇到错误。请稍后再试。",
|
||||
"zh-TW": "系統發生錯誤。請稍後再試。",
|
||||
"ko-KR": "시스템에 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
|
||||
"no": "Systemet har oppdaget en feil. Prøv igjen senere.",
|
||||
"it": "Il sistema ha riscontrato un errore. Riprova più tardi.",
|
||||
"pt": "O sistema encontrou um erro. Por favor, tente novamente mais tarde.",
|
||||
"es": "El sistema ha encontrado un error. Por favor, inténtalo de nuevo más tarde.",
|
||||
"ar": "واجه النظام خطأ. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"fr": "Le système a rencontré une erreur. Veuillez réessayer plus tard.",
|
||||
"tr": "Sistem bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin.",
|
||||
"de": "Das System hat einen Fehler festgestellt. Bitte versuchen Sie es später erneut.",
|
||||
"uk": "Система зіткнулася з помилкою. Будь ласка, спробуйте пізніше."
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED": {
|
||||
"en": "The conversation has been stopped.",
|
||||
"ja": "会話が停止されました。",
|
||||
"zh-CN": "对话已被停止。",
|
||||
"zh-TW": "對話已被停止。",
|
||||
"ko-KR": "대화가 중단되었습니다.",
|
||||
"no": "Samtalen har blitt stoppet.",
|
||||
"it": "La conversazione è stata interrotta.",
|
||||
"pt": "A conversa foi interrompida.",
|
||||
"es": "La conversación ha sido detenida.",
|
||||
"ar": "تم إيقاف المحادثة.",
|
||||
"fr": "La conversation a été arrêtée.",
|
||||
"tr": "Konuşma durduruldu.",
|
||||
"de": "Das Gespräch wurde gestoppt.",
|
||||
"uk": "Розмову зупинено."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ function SettingsScreen() {
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
|
||||
// THIS IS A TEST
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="settings-screen"
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { IMicroagentItem } from "#/types/microagent-management";
|
||||
|
||||
export const microagentManagementSlice = createSlice({
|
||||
name: "microagentManagement",
|
||||
initialState: {
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: null as GitRepository | null,
|
||||
personalRepositories: [] as GitRepository[],
|
||||
organizationRepositories: [] as GitRepository[],
|
||||
repositories: [] as GitRepository[],
|
||||
selectedMicroagentItem: null as IMicroagentItem | null,
|
||||
},
|
||||
reducers: {
|
||||
setSelectedMicroagent: (state, action) => {
|
||||
state.selectedMicroagent = action.payload;
|
||||
},
|
||||
setAddMicroagentModalVisible: (state, action) => {
|
||||
state.addMicroagentModalVisible = action.payload;
|
||||
},
|
||||
@@ -28,19 +30,16 @@ export const microagentManagementSlice = createSlice({
|
||||
setRepositories: (state, action) => {
|
||||
state.repositories = action.payload;
|
||||
},
|
||||
setSelectedMicroagentItem: (state, action) => {
|
||||
state.selectedMicroagentItem = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setSelectedMicroagent,
|
||||
setAddMicroagentModalVisible,
|
||||
setSelectedRepository,
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
setRepositories,
|
||||
setSelectedMicroagentItem,
|
||||
} = microagentManagementSlice.actions;
|
||||
|
||||
export default microagentManagementSlice.reducer;
|
||||
|
||||
@@ -20,27 +20,3 @@
|
||||
.heading {
|
||||
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
|
||||
}
|
||||
|
||||
.loader {
|
||||
background: #C9B974;
|
||||
animation: l5 1s infinite linear alternate;
|
||||
}
|
||||
|
||||
@keyframes l5 {
|
||||
0% {
|
||||
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
|
||||
background: #C9B974;
|
||||
}
|
||||
33% {
|
||||
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
|
||||
background: rgba(201,185,116,0.1);
|
||||
}
|
||||
66% {
|
||||
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
|
||||
background: rgba(201,185,116,0.1);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
|
||||
background: #C9B974;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
export type TabType = "personal" | "repositories" | "organizations";
|
||||
|
||||
export interface RepositoryMicroagent {
|
||||
@@ -11,12 +9,6 @@ export interface RepositoryMicroagent {
|
||||
tools: string[];
|
||||
created_at: string;
|
||||
git_provider: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface IMicroagentItem {
|
||||
microagent?: RepositoryMicroagent;
|
||||
conversation?: Conversation;
|
||||
}
|
||||
|
||||
export interface MicroagentFormData {
|
||||
|
||||
@@ -142,68 +142,3 @@ export const getPR = (isGitLab: boolean) =>
|
||||
* @returns The short name of the PR
|
||||
*/
|
||||
export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR");
|
||||
|
||||
/**
|
||||
* Construct the pull request (merge request) URL for different providers
|
||||
* @param prNumber The pull request number
|
||||
* @param provider The git provider
|
||||
* @param repositoryName The repository name in format "owner/repo"
|
||||
* @returns The pull request URL
|
||||
*
|
||||
* @example
|
||||
* constructPullRequestUrl(123, "github", "owner/repo") // "https://github.com/owner/repo/pull/123"
|
||||
* constructPullRequestUrl(456, "gitlab", "owner/repo") // "https://gitlab.com/owner/repo/-/merge_requests/456"
|
||||
* constructPullRequestUrl(789, "bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo/pull-requests/789"
|
||||
*/
|
||||
export const constructPullRequestUrl = (
|
||||
prNumber: number,
|
||||
provider: Provider,
|
||||
repositoryName: string,
|
||||
): string => {
|
||||
const baseUrl = getGitProviderBaseUrl(provider);
|
||||
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return `${baseUrl}/${repositoryName}/pull/${prNumber}`;
|
||||
case "gitlab":
|
||||
return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`;
|
||||
case "bitbucket":
|
||||
return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Construct the microagent URL for different providers
|
||||
* @param gitProvider The git provider
|
||||
* @param repositoryName The repository name in format "owner/repo"
|
||||
* @param microagentPath The path to the microagent in the repository
|
||||
* @returns The URL to the microagent file in the Git provider
|
||||
*
|
||||
* @example
|
||||
* constructMicroagentUrl("github", "owner/repo", ".openhands/microagents/tell-me-a-joke.md")
|
||||
* // "https://github.com/owner/repo/blob/main/.openhands/microagents/tell-me-a-joke.md"
|
||||
* constructMicroagentUrl("gitlab", "owner/repo", "microagents/git-helper.md")
|
||||
* // "https://gitlab.com/owner/repo/-/blob/main/microagents/git-helper.md"
|
||||
* constructMicroagentUrl("bitbucket", "owner/repo", ".openhands/microagents/docker-helper.md")
|
||||
* // "https://bitbucket.org/owner/repo/src/main/.openhands/microagents/docker-helper.md"
|
||||
*/
|
||||
export const constructMicroagentUrl = (
|
||||
gitProvider: Provider,
|
||||
repositoryName: string,
|
||||
microagentPath: string,
|
||||
): string => {
|
||||
const baseUrl = getGitProviderBaseUrl(gitProvider);
|
||||
|
||||
switch (gitProvider) {
|
||||
case "github":
|
||||
return `${baseUrl}/${repositoryName}/blob/main/${microagentPath}`;
|
||||
case "gitlab":
|
||||
return `${baseUrl}/${repositoryName}/-/blob/main/${microagentPath}`;
|
||||
case "bitbucket":
|
||||
return `${baseUrl}/${repositoryName}/src/main/${microagentPath}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,10 +72,7 @@ from openhands.runtime.utils.bash import BashSession
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
from openhands.runtime.utils.memory_monitor import MemoryMonitor
|
||||
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
|
||||
from openhands.runtime.utils.system_stats import (
|
||||
get_system_stats,
|
||||
update_last_execution_time,
|
||||
)
|
||||
from openhands.runtime.utils.system_stats import get_system_stats
|
||||
from openhands.utils.async_utils import call_sync_from_async, wait_all
|
||||
|
||||
if sys.platform == 'win32':
|
||||
@@ -847,8 +844,6 @@ if __name__ == '__main__':
|
||||
status_code=500,
|
||||
detail=traceback.format_exc(),
|
||||
)
|
||||
finally:
|
||||
update_last_execution_time()
|
||||
|
||||
@app.post('/update_mcp_server')
|
||||
async def update_mcp_server(request: Request):
|
||||
|
||||
@@ -46,7 +46,6 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.runtime.utils.system_stats import update_last_execution_time
|
||||
from openhands.utils.http_session import HttpSession
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
@@ -329,8 +328,6 @@ class ActionExecutionClient(Runtime):
|
||||
raise AgentRuntimeTimeoutError(
|
||||
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
|
||||
)
|
||||
finally:
|
||||
update_last_execution_time()
|
||||
return obs
|
||||
|
||||
def run(self, action: CmdRunAction) -> Observation:
|
||||
|
||||
@@ -31,7 +31,6 @@ from openhands.runtime.utils.command import (
|
||||
get_action_execution_server_startup_command,
|
||||
)
|
||||
from openhands.runtime.utils.log_streamer import LogStreamer
|
||||
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import add_shutdown_listener
|
||||
@@ -105,11 +104,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
self._vscode_port = -1
|
||||
self._app_ports: list[int] = []
|
||||
|
||||
# Port locks to prevent race conditions
|
||||
self._host_port_lock: PortLock | None = None
|
||||
self._vscode_port_lock: PortLock | None = None
|
||||
self._app_port_locks: list[PortLock] = []
|
||||
|
||||
if os.environ.get('DOCKER_HOST_ADDR'):
|
||||
logger.info(
|
||||
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url'
|
||||
@@ -282,31 +276,17 @@ class DockerRuntime(ActionExecutionClient):
|
||||
def init_container(self) -> None:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
# Allocate host port with locking to prevent race conditions
|
||||
self._host_port, self._host_port_lock = self._find_available_port_with_lock(
|
||||
EXECUTION_SERVER_PORT_RANGE
|
||||
)
|
||||
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
|
||||
self._container_port = self._host_port
|
||||
|
||||
# Use the configured vscode_port if provided, otherwise find an available port
|
||||
if self.config.sandbox.vscode_port:
|
||||
self._vscode_port = self.config.sandbox.vscode_port
|
||||
self._vscode_port_lock = None # No lock needed for configured port
|
||||
else:
|
||||
self._vscode_port, self._vscode_port_lock = (
|
||||
self._find_available_port_with_lock(VSCODE_PORT_RANGE)
|
||||
)
|
||||
|
||||
# Allocate app ports with locking
|
||||
app_port_1, app_lock_1 = self._find_available_port_with_lock(APP_PORT_RANGE_1)
|
||||
app_port_2, app_lock_2 = self._find_available_port_with_lock(APP_PORT_RANGE_2)
|
||||
|
||||
self._app_ports = [app_port_1, app_port_2]
|
||||
self._app_port_locks = [
|
||||
lock for lock in [app_lock_1, app_lock_2] if lock is not None
|
||||
self._vscode_port = (
|
||||
self.config.sandbox.vscode_port
|
||||
or self._find_available_port(VSCODE_PORT_RANGE)
|
||||
)
|
||||
self._app_ports = [
|
||||
self._find_available_port(APP_PORT_RANGE_1),
|
||||
self._find_available_port(APP_PORT_RANGE_2),
|
||||
]
|
||||
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
|
||||
use_host_network = self.config.sandbox.use_host_network
|
||||
@@ -496,28 +476,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
|
||||
)
|
||||
stop_all_containers(close_prefix)
|
||||
self._release_port_locks()
|
||||
|
||||
def _release_port_locks(self) -> None:
|
||||
"""Release all acquired port locks."""
|
||||
if self._host_port_lock:
|
||||
self._host_port_lock.release()
|
||||
self._host_port_lock = None
|
||||
logger.debug(f'Released host port lock for port {self._host_port}')
|
||||
|
||||
if self._vscode_port_lock:
|
||||
self._vscode_port_lock.release()
|
||||
self._vscode_port_lock = None
|
||||
logger.debug(f'Released VSCode port lock for port {self._vscode_port}')
|
||||
|
||||
for i, lock in enumerate(self._app_port_locks):
|
||||
if lock:
|
||||
lock.release()
|
||||
logger.debug(
|
||||
f'Released app port lock for port {self._app_ports[i] if i < len(self._app_ports) else "unknown"}'
|
||||
)
|
||||
|
||||
self._app_port_locks.clear()
|
||||
|
||||
def _is_port_in_use_docker(self, port: int) -> bool:
|
||||
containers = self.docker_client.containers.list()
|
||||
@@ -527,58 +485,15 @@ class DockerRuntime(ActionExecutionClient):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _find_available_port_with_lock(
|
||||
self, port_range: tuple[int, int], max_attempts: int = 5
|
||||
) -> tuple[int, PortLock | None]:
|
||||
"""Find an available port with race condition protection.
|
||||
|
||||
This method uses file-based locking to prevent multiple workers
|
||||
from allocating the same port simultaneously.
|
||||
|
||||
Args:
|
||||
port_range: Tuple of (min_port, max_port)
|
||||
max_attempts: Maximum number of attempts to find a port
|
||||
|
||||
Returns:
|
||||
Tuple of (port_number, port_lock) where port_lock may be None if locking failed
|
||||
"""
|
||||
# Try to find and lock an available port
|
||||
result = find_available_port_with_lock(
|
||||
min_port=port_range[0],
|
||||
max_port=port_range[1],
|
||||
max_attempts=max_attempts,
|
||||
bind_address='0.0.0.0',
|
||||
lock_timeout=1.0,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
# Fallback to original method if port locking fails
|
||||
logger.warning(
|
||||
f'Port locking failed for range {port_range}, falling back to original method'
|
||||
)
|
||||
port = port_range[1]
|
||||
for _ in range(max_attempts):
|
||||
port = find_available_tcp_port(port_range[0], port_range[1])
|
||||
if not self._is_port_in_use_docker(port):
|
||||
return port, None
|
||||
return port, None
|
||||
|
||||
port, port_lock = result
|
||||
|
||||
# Additional check with Docker to ensure port is not in use
|
||||
if self._is_port_in_use_docker(port):
|
||||
port_lock.release()
|
||||
# Try again with a different port
|
||||
logger.debug(f'Port {port} is in use by Docker, trying again')
|
||||
return self._find_available_port_with_lock(port_range, max_attempts - 1)
|
||||
|
||||
return port, port_lock
|
||||
|
||||
def _find_available_port(
|
||||
self, port_range: tuple[int, int], max_attempts: int = 5
|
||||
) -> int:
|
||||
"""Find an available port (legacy method for backward compatibility)."""
|
||||
port, _ = self._find_available_port_with_lock(port_range, max_attempts)
|
||||
port = port_range[1]
|
||||
for _ in range(max_attempts):
|
||||
port = find_available_tcp_port(port_range[0], port_range[1])
|
||||
if not self._is_port_in_use_docker(port):
|
||||
return port
|
||||
# If no port is found after max_attempts, return the last tried port
|
||||
return port
|
||||
|
||||
@property
|
||||
|
||||
@@ -4,7 +4,8 @@ from typing import Callable
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Represents the result of a shell command execution.
|
||||
"""
|
||||
Represents the result of a shell command execution.
|
||||
|
||||
Attributes:
|
||||
content (str): The output content of the command.
|
||||
@@ -16,7 +17,9 @@ class CommandResult:
|
||||
|
||||
|
||||
class GitHandler:
|
||||
"""A handler for executing Git-related operations via shell commands."""
|
||||
"""
|
||||
A handler for executing Git-related operations via shell commands.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -26,7 +29,8 @@ class GitHandler:
|
||||
self.cwd: str | None = None
|
||||
|
||||
def set_cwd(self, cwd: str) -> None:
|
||||
"""Sets the current working directory for Git operations.
|
||||
"""
|
||||
Sets the current working directory for Git operations.
|
||||
|
||||
Args:
|
||||
cwd (str): The directory path.
|
||||
@@ -34,7 +38,8 @@ class GitHandler:
|
||||
self.cwd = cwd
|
||||
|
||||
def _is_git_repo(self) -> bool:
|
||||
"""Checks if the current directory is a Git repository.
|
||||
"""
|
||||
Checks if the current directory is a Git repository.
|
||||
|
||||
Returns:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
@@ -44,7 +49,8 @@ class GitHandler:
|
||||
return output.content.strip() == 'true'
|
||||
|
||||
def _get_current_file_content(self, file_path: str) -> str:
|
||||
"""Retrieves the current content of a given file.
|
||||
"""
|
||||
Retrieves the current content of a given file.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file.
|
||||
@@ -56,7 +62,8 @@ class GitHandler:
|
||||
return output.content
|
||||
|
||||
def _verify_ref_exists(self, ref: str) -> bool:
|
||||
"""Verifies whether a specific Git reference exists.
|
||||
"""
|
||||
Verifies whether a specific Git reference exists.
|
||||
|
||||
Args:
|
||||
ref (str): The Git reference to check.
|
||||
@@ -68,71 +75,10 @@ class GitHandler:
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
def _is_ahead_of_remote_branch(self, remote_branch: str) -> bool:
|
||||
"""Checks if the current branch is ahead of the specified remote branch.
|
||||
|
||||
Args:
|
||||
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
|
||||
|
||||
Returns:
|
||||
bool: True if current branch is ahead, False otherwise.
|
||||
"""
|
||||
cmd = f'git --no-pager rev-list --count {remote_branch}..HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code != 0:
|
||||
return False
|
||||
return int(output.content.strip()) > 0
|
||||
|
||||
def _includes_merged_main_commits(self, remote_branch: str, default_branch: str) -> bool:
|
||||
"""Checks if the local branch includes commits that were merged from the default branch.
|
||||
|
||||
Since the remote branch was last updated.
|
||||
|
||||
Args:
|
||||
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
|
||||
default_branch (str): The default branch name (e.g., 'main').
|
||||
|
||||
Returns:
|
||||
bool: True if merged main commits are included in the diff.
|
||||
"""
|
||||
# Get commits that are in HEAD but not in remote_branch
|
||||
cmd = f'git --no-pager log --oneline {remote_branch}..HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code != 0:
|
||||
return False
|
||||
|
||||
local_commits = output.content.strip().splitlines()
|
||||
if not local_commits:
|
||||
return False
|
||||
|
||||
# Get commits that are in origin/default_branch but not in remote_branch
|
||||
origin_default = f'origin/{default_branch}'
|
||||
if not self._verify_ref_exists(origin_default):
|
||||
return False
|
||||
|
||||
cmd = f'git --no-pager log --oneline {remote_branch}..{origin_default}'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code != 0:
|
||||
return False
|
||||
|
||||
main_commits = output.content.strip().splitlines()
|
||||
if not main_commits:
|
||||
return False
|
||||
|
||||
# Extract commit hashes from both lists
|
||||
local_hashes = {line.split()[0] for line in local_commits if line.strip()}
|
||||
main_hashes = {line.split()[0] for line in main_commits if line.strip()}
|
||||
|
||||
# If there's significant overlap, we likely have merged main commits
|
||||
overlap = local_hashes.intersection(main_hashes)
|
||||
return len(overlap) >= min(2, len(main_hashes) // 2)
|
||||
|
||||
def _get_valid_ref(self) -> str | None:
|
||||
"""Determines a valid Git reference for comparison using a hybrid approach.
|
||||
|
||||
- Uses origin/current_branch when it's the best representation of push status
|
||||
- Falls back to merge-base when origin/current_branch includes merged main commits
|
||||
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
@@ -144,19 +90,8 @@ class GitHandler:
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
# Hybrid logic: check if origin/current_branch exists and causes pollution
|
||||
if self._verify_ref_exists(ref_current_branch):
|
||||
# If we're ahead of remote and it includes merged main commits, use merge-base instead
|
||||
if (self._is_ahead_of_remote_branch(ref_current_branch) and
|
||||
self._includes_merged_main_commits(ref_current_branch, default_branch)):
|
||||
# Try merge-base first to avoid pollution
|
||||
if self._verify_ref_exists(ref_non_default_branch):
|
||||
return ref_non_default_branch
|
||||
# Otherwise use origin/current_branch for normal push workflow
|
||||
return ref_current_branch
|
||||
|
||||
# Fallback to original logic
|
||||
refs = [
|
||||
ref_current_branch,
|
||||
ref_non_default_branch,
|
||||
ref_default_branch,
|
||||
ref_new_repo,
|
||||
@@ -168,7 +103,8 @@ class GitHandler:
|
||||
return None
|
||||
|
||||
def _get_ref_content(self, file_path: str) -> str:
|
||||
"""Retrieves the content of a file from a valid Git reference.
|
||||
"""
|
||||
Retrieves the content of a file from a valid Git reference.
|
||||
|
||||
Args:
|
||||
file_path (str): The file path in the repository.
|
||||
@@ -185,7 +121,8 @@ class GitHandler:
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
def _get_default_branch(self) -> str:
|
||||
"""Retrieves the primary Git branch name of the repository.
|
||||
"""
|
||||
Retrieves the primary Git branch name of the repository.
|
||||
|
||||
Returns:
|
||||
str: The name of the primary branch.
|
||||
@@ -195,7 +132,8 @@ class GitHandler:
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""Retrieves the currently selected Git branch.
|
||||
"""
|
||||
Retrieves the currently selected Git branch.
|
||||
|
||||
Returns:
|
||||
str: The name of the current branch.
|
||||
@@ -205,7 +143,8 @@ class GitHandler:
|
||||
return output.content.strip()
|
||||
|
||||
def _get_changed_files(self) -> list[str]:
|
||||
"""Retrieves a list of changed files compared to a valid Git reference.
|
||||
"""
|
||||
Retrieves a list of changed files compared to a valid Git reference.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of changed file paths.
|
||||
@@ -223,7 +162,8 @@ class GitHandler:
|
||||
return output.content.splitlines()
|
||||
|
||||
def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
"""Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
"""
|
||||
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
@@ -238,7 +178,8 @@ class GitHandler:
|
||||
)
|
||||
|
||||
def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
"""Retrieves the list of changed files in the Git repository.
|
||||
"""
|
||||
Retrieves the list of changed files in the Git repository.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if not a git repository.
|
||||
@@ -254,7 +195,8 @@ class GitHandler:
|
||||
return result
|
||||
|
||||
def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
"""Retrieves the original and modified content of a file in the repository.
|
||||
"""
|
||||
Retrieves the original and modified content of a file in the repository.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file.
|
||||
@@ -272,7 +214,8 @@ class GitHandler:
|
||||
|
||||
|
||||
def parse_git_changes(changes_list: list[str]) -> list[dict[str, str]]:
|
||||
"""Parses the list of changed files and extracts their statuses and paths.
|
||||
"""
|
||||
Parses the list of changed files and extracts their statuses and paths.
|
||||
|
||||
Args:
|
||||
changes_list (list[str]): List of changed file entries.
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
"""File-based port locking system for preventing race conditions in port allocation."""
|
||||
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Import fcntl only on Unix systems
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
HAS_FCNTL = True
|
||||
except ImportError:
|
||||
HAS_FCNTL = False
|
||||
|
||||
|
||||
class PortLock:
|
||||
"""File-based lock for a specific port to prevent race conditions."""
|
||||
|
||||
def __init__(self, port: int, lock_dir: Optional[str] = None):
|
||||
self.port = port
|
||||
self.lock_dir = lock_dir or os.path.join(
|
||||
tempfile.gettempdir(), 'openhands_port_locks'
|
||||
)
|
||||
self.lock_file_path = os.path.join(self.lock_dir, f'port_{port}.lock')
|
||||
self.lock_fd: Optional[int] = None
|
||||
self._locked = False
|
||||
|
||||
# Ensure lock directory exists
|
||||
os.makedirs(self.lock_dir, exist_ok=True)
|
||||
|
||||
def acquire(self, timeout: float = 1.0) -> bool:
|
||||
"""Acquire the lock for this port.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for the lock
|
||||
|
||||
Returns:
|
||||
True if lock was acquired, False otherwise
|
||||
"""
|
||||
if self._locked:
|
||||
return True
|
||||
|
||||
try:
|
||||
if HAS_FCNTL:
|
||||
# Unix-style file locking with fcntl
|
||||
self.lock_fd = os.open(
|
||||
self.lock_file_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC
|
||||
)
|
||||
|
||||
# Try to acquire exclusive lock with timeout
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
self._locked = True
|
||||
|
||||
# Write port number to lock file for debugging
|
||||
os.write(self.lock_fd, f'{self.port}\n'.encode())
|
||||
os.fsync(self.lock_fd)
|
||||
|
||||
logger.debug(f'Acquired lock for port {self.port}')
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
# Lock is held by another process, wait a bit
|
||||
time.sleep(0.01)
|
||||
|
||||
# Timeout reached
|
||||
if self.lock_fd:
|
||||
os.close(self.lock_fd)
|
||||
self.lock_fd = None
|
||||
return False
|
||||
else:
|
||||
# Windows fallback: use atomic file creation
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# Try to create lock file exclusively
|
||||
self.lock_fd = os.open(
|
||||
self.lock_file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
||||
)
|
||||
self._locked = True
|
||||
|
||||
# Write port number to lock file for debugging
|
||||
os.write(self.lock_fd, f'{self.port}\n'.encode())
|
||||
os.fsync(self.lock_fd)
|
||||
|
||||
logger.debug(f'Acquired lock for port {self.port}')
|
||||
return True
|
||||
except OSError:
|
||||
# Lock file already exists, wait a bit
|
||||
time.sleep(0.01)
|
||||
|
||||
# Timeout reached
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to acquire lock for port {self.port}: {e}')
|
||||
if self.lock_fd:
|
||||
try:
|
||||
os.close(self.lock_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self.lock_fd = None
|
||||
return False
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the lock."""
|
||||
if self.lock_fd is not None:
|
||||
try:
|
||||
if HAS_FCNTL:
|
||||
# Unix: unlock and close
|
||||
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
os.close(self.lock_fd)
|
||||
|
||||
# Remove lock file (both Unix and Windows)
|
||||
try:
|
||||
os.unlink(self.lock_file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
logger.debug(f'Released lock for port {self.port}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error releasing lock for port {self.port}: {e}')
|
||||
finally:
|
||||
self.lock_fd = None
|
||||
self._locked = False
|
||||
|
||||
def __enter__(self) -> 'PortLock':
|
||||
if not self.acquire():
|
||||
raise OSError(f'Could not acquire lock for port {self.port}')
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.release()
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool:
|
||||
return self._locked
|
||||
|
||||
|
||||
def find_available_port_with_lock(
|
||||
min_port: int = 30000,
|
||||
max_port: int = 39999,
|
||||
max_attempts: int = 20,
|
||||
bind_address: str = '0.0.0.0',
|
||||
lock_timeout: float = 1.0,
|
||||
) -> Optional[tuple[int, PortLock]]:
|
||||
"""Find an available port and acquire a lock for it.
|
||||
|
||||
This function combines file-based locking with port availability checking
|
||||
to prevent race conditions in multi-process scenarios.
|
||||
|
||||
Args:
|
||||
min_port: Minimum port number to try
|
||||
max_port: Maximum port number to try
|
||||
max_attempts: Maximum number of ports to try
|
||||
bind_address: Address to bind to when checking availability
|
||||
lock_timeout: Timeout for acquiring port lock
|
||||
|
||||
Returns:
|
||||
Tuple of (port, lock) if successful, None otherwise
|
||||
"""
|
||||
rng = random.SystemRandom()
|
||||
|
||||
# Try random ports first for better distribution
|
||||
random_attempts = min(max_attempts // 2, 10)
|
||||
for _ in range(random_attempts):
|
||||
port = rng.randint(min_port, max_port)
|
||||
|
||||
# Try to acquire lock first
|
||||
lock = PortLock(port)
|
||||
if lock.acquire(timeout=lock_timeout):
|
||||
# Check if port is actually available
|
||||
if _check_port_available(port, bind_address):
|
||||
logger.debug(f'Found and locked available port {port}')
|
||||
return port, lock
|
||||
else:
|
||||
# Port is locked but not available (maybe in TIME_WAIT state)
|
||||
lock.release()
|
||||
|
||||
# Small delay to reduce contention
|
||||
time.sleep(0.001)
|
||||
|
||||
# If random attempts failed, try sequential search
|
||||
remaining_attempts = max_attempts - random_attempts
|
||||
start_port = rng.randint(min_port, max_port - remaining_attempts)
|
||||
|
||||
for i in range(remaining_attempts):
|
||||
port = start_port + i
|
||||
if port > max_port:
|
||||
port = min_port + (port - max_port - 1)
|
||||
|
||||
# Try to acquire lock first
|
||||
lock = PortLock(port)
|
||||
if lock.acquire(timeout=lock_timeout):
|
||||
# Check if port is actually available
|
||||
if _check_port_available(port, bind_address):
|
||||
logger.debug(f'Found and locked available port {port}')
|
||||
return port, lock
|
||||
else:
|
||||
# Port is locked but not available
|
||||
lock.release()
|
||||
|
||||
# Small delay to reduce contention
|
||||
time.sleep(0.001)
|
||||
|
||||
logger.error(
|
||||
f'Could not find and lock available port in range {min_port}-{max_port} after {max_attempts} attempts'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _check_port_available(port: int, bind_address: str = '0.0.0.0') -> bool:
|
||||
"""Check if a port is available by trying to bind to it."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((bind_address, port))
|
||||
sock.close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_stale_locks(max_age_seconds: int = 300) -> int:
|
||||
"""Clean up stale lock files.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age of lock files before they're considered stale
|
||||
|
||||
Returns:
|
||||
Number of lock files cleaned up
|
||||
"""
|
||||
lock_dir = os.path.join(tempfile.gettempdir(), 'openhands_port_locks')
|
||||
if not os.path.exists(lock_dir):
|
||||
return 0
|
||||
|
||||
cleaned = 0
|
||||
current_time = time.time()
|
||||
|
||||
try:
|
||||
for filename in os.listdir(lock_dir):
|
||||
if filename.startswith('port_') and filename.endswith('.lock'):
|
||||
lock_path = os.path.join(lock_dir, filename)
|
||||
try:
|
||||
# Check if lock file is old
|
||||
stat = os.stat(lock_path)
|
||||
if current_time - stat.st_mtime > max_age_seconds:
|
||||
# Try to remove stale lock
|
||||
os.unlink(lock_path)
|
||||
cleaned += 1
|
||||
logger.debug(f'Cleaned up stale lock file: {filename}')
|
||||
except (OSError, FileNotFoundError):
|
||||
# File might have been removed by another process
|
||||
pass
|
||||
except OSError:
|
||||
# Directory might not exist or be accessible
|
||||
pass
|
||||
|
||||
if cleaned > 0:
|
||||
logger.info(f'Cleaned up {cleaned} stale port lock files')
|
||||
|
||||
return cleaned
|
||||
@@ -4,25 +4,6 @@ import time
|
||||
|
||||
import psutil
|
||||
|
||||
_start_time = time.time()
|
||||
_last_execution_time = time.time()
|
||||
|
||||
|
||||
def get_system_info() -> dict[str, object]:
|
||||
current_time = time.time()
|
||||
uptime = current_time - _start_time
|
||||
idle_time = current_time - _last_execution_time
|
||||
return {
|
||||
'uptime': uptime,
|
||||
'idle_time': idle_time,
|
||||
'resources': get_system_stats(),
|
||||
}
|
||||
|
||||
|
||||
def update_last_execution_time():
|
||||
global _last_execution_time
|
||||
_last_execution_time = time.time()
|
||||
|
||||
|
||||
def get_system_stats() -> dict[str, object]:
|
||||
"""Get current system resource statistics.
|
||||
|
||||
@@ -254,7 +254,6 @@ class MicroagentResponse(BaseModel):
|
||||
tools: list[str] = []
|
||||
created_at: datetime
|
||||
git_provider: ProviderType
|
||||
path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke")
|
||||
|
||||
|
||||
def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime:
|
||||
@@ -454,7 +453,6 @@ def _process_microagents(
|
||||
),
|
||||
created_at=created_at,
|
||||
git_provider=git_provider,
|
||||
path=str(agent_file_path.relative_to(repo_dir)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -478,7 +476,6 @@ def _process_microagents(
|
||||
),
|
||||
created_at=created_at,
|
||||
git_provider=git_provider,
|
||||
path=str(agent_file_path.relative_to(repo_dir)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from fastapi import FastAPI
|
||||
import time
|
||||
|
||||
from openhands.runtime.utils.system_stats import get_system_info
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from openhands.runtime.utils.system_stats import get_system_stats
|
||||
|
||||
start_time = time.time()
|
||||
last_execution_time = start_time
|
||||
|
||||
|
||||
def add_health_endpoints(app: FastAPI):
|
||||
@@ -14,4 +19,20 @@ def add_health_endpoints(app: FastAPI):
|
||||
|
||||
@app.get('/server_info')
|
||||
async def get_server_info():
|
||||
return get_system_info()
|
||||
current_time = time.time()
|
||||
uptime = current_time - start_time
|
||||
idle_time = current_time - last_execution_time
|
||||
|
||||
response = {
|
||||
'uptime': uptime,
|
||||
'idle_time': idle_time,
|
||||
'resources': get_system_stats(),
|
||||
}
|
||||
return response
|
||||
|
||||
@app.middleware('http')
|
||||
async def update_last_execution_time(request: Request, call_next):
|
||||
global last_execution_time
|
||||
response = await call_next(request)
|
||||
last_execution_time = time.time()
|
||||
return response
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Test for port allocation race condition fix."""
|
||||
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
|
||||
|
||||
|
||||
class TestPortLockingFix:
|
||||
"""Test cases for port allocation race condition fix."""
|
||||
|
||||
def test_port_lock_prevents_duplicate_allocation(self):
|
||||
"""Test that port locking prevents duplicate port allocation."""
|
||||
allocated_ports = []
|
||||
port_locks = []
|
||||
|
||||
def allocate_port():
|
||||
"""Simulate port allocation by multiple workers."""
|
||||
result = find_available_port_with_lock(
|
||||
min_port=30000,
|
||||
max_port=30010, # Small range to force conflicts
|
||||
max_attempts=5,
|
||||
bind_address='0.0.0.0',
|
||||
lock_timeout=2.0,
|
||||
)
|
||||
|
||||
if result:
|
||||
port, lock = result
|
||||
allocated_ports.append(port)
|
||||
port_locks.append(lock)
|
||||
# Simulate some work time
|
||||
time.sleep(0.1)
|
||||
return port
|
||||
return None
|
||||
|
||||
# Run multiple threads concurrently
|
||||
num_workers = 8
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures = [executor.submit(allocate_port) for _ in range(num_workers)]
|
||||
results = [future.result() for future in as_completed(futures)]
|
||||
|
||||
# Filter out None results
|
||||
successful_ports = [port for port in results if port is not None]
|
||||
|
||||
# Verify no duplicate ports were allocated
|
||||
assert len(successful_ports) == len(set(successful_ports)), (
|
||||
f'Duplicate ports allocated: {successful_ports}'
|
||||
)
|
||||
|
||||
# Clean up locks
|
||||
for lock in port_locks:
|
||||
if lock:
|
||||
lock.release()
|
||||
|
||||
print(
|
||||
f'Successfully allocated {len(successful_ports)} unique ports: {successful_ports}'
|
||||
)
|
||||
|
||||
def test_port_lock_basic_functionality(self):
|
||||
"""Test basic port lock functionality."""
|
||||
port = 30001
|
||||
|
||||
# Test acquiring and releasing a lock
|
||||
lock1 = PortLock(port)
|
||||
assert lock1.acquire(timeout=1.0)
|
||||
assert lock1.is_locked
|
||||
|
||||
# Test that another lock cannot acquire the same port
|
||||
lock2 = PortLock(port)
|
||||
assert not lock2.acquire(timeout=0.1)
|
||||
assert not lock2.is_locked
|
||||
|
||||
# Release first lock
|
||||
lock1.release()
|
||||
assert not lock1.is_locked
|
||||
|
||||
# Now second lock should be able to acquire
|
||||
assert lock2.acquire(timeout=1.0)
|
||||
assert lock2.is_locked
|
||||
|
||||
lock2.release()
|
||||
|
||||
def test_port_lock_context_manager(self):
|
||||
"""Test port lock context manager functionality."""
|
||||
port = 30002
|
||||
|
||||
# Test successful context manager usage
|
||||
with PortLock(port) as lock:
|
||||
assert lock.is_locked
|
||||
|
||||
# Test that another lock cannot acquire while in context
|
||||
lock2 = PortLock(port)
|
||||
assert not lock2.acquire(timeout=0.1)
|
||||
|
||||
# After context, lock should be released
|
||||
assert not lock.is_locked
|
||||
|
||||
# Now another lock should be able to acquire
|
||||
lock3 = PortLock(port)
|
||||
assert lock3.acquire(timeout=1.0)
|
||||
lock3.release()
|
||||
|
||||
def test_concurrent_port_allocation_stress_test(self):
|
||||
"""Stress test concurrent port allocation."""
|
||||
allocated_ports = []
|
||||
port_locks = []
|
||||
errors = []
|
||||
|
||||
def worker_allocate_port(worker_id):
|
||||
"""Worker function that allocates a port."""
|
||||
try:
|
||||
result = find_available_port_with_lock(
|
||||
min_port=31000,
|
||||
max_port=31020, # Small range to force contention
|
||||
max_attempts=10,
|
||||
bind_address='0.0.0.0',
|
||||
lock_timeout=3.0,
|
||||
)
|
||||
|
||||
if result:
|
||||
port, lock = result
|
||||
allocated_ports.append((worker_id, port))
|
||||
port_locks.append(lock)
|
||||
# Simulate work
|
||||
time.sleep(0.05)
|
||||
return port
|
||||
else:
|
||||
errors.append(f'Worker {worker_id}: No port available')
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f'Worker {worker_id}: {str(e)}')
|
||||
return None
|
||||
|
||||
# Run many workers concurrently
|
||||
num_workers = 15
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(worker_allocate_port, i): i for i in range(num_workers)
|
||||
}
|
||||
results = {}
|
||||
for future in as_completed(futures):
|
||||
worker_id = futures[future]
|
||||
try:
|
||||
result = future.result()
|
||||
results[worker_id] = result
|
||||
except Exception as e:
|
||||
errors.append(f'Worker {worker_id} exception: {str(e)}')
|
||||
|
||||
# Analyze results
|
||||
successful_allocations = [
|
||||
(wid, port) for wid, port in allocated_ports if port is not None
|
||||
]
|
||||
allocated_port_numbers = [port for _, port in successful_allocations]
|
||||
|
||||
print(f'Successful allocations: {len(successful_allocations)}')
|
||||
print(f'Allocated ports: {allocated_port_numbers}')
|
||||
print(f'Errors: {len(errors)}')
|
||||
if errors:
|
||||
print(f'Error details: {errors[:5]}') # Show first 5 errors
|
||||
|
||||
# Verify no duplicate ports
|
||||
unique_ports = set(allocated_port_numbers)
|
||||
assert len(allocated_port_numbers) == len(unique_ports), (
|
||||
f'Duplicate ports found: {allocated_port_numbers}'
|
||||
)
|
||||
|
||||
# Clean up locks
|
||||
for lock in port_locks:
|
||||
if lock:
|
||||
lock.release()
|
||||
|
||||
def test_port_allocation_without_locking_shows_race_condition(self):
|
||||
"""Test that demonstrates race condition without locking."""
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
|
||||
allocated_ports = []
|
||||
|
||||
def allocate_port_without_lock():
|
||||
"""Simulate port allocation without locking (old method)."""
|
||||
# This simulates the old behavior that had race conditions
|
||||
port = find_available_tcp_port(32000, 32010)
|
||||
allocated_ports.append(port)
|
||||
# Small delay to increase chance of race condition
|
||||
time.sleep(0.01)
|
||||
return port
|
||||
|
||||
# Run multiple threads concurrently
|
||||
num_workers = 10
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(allocate_port_without_lock) for _ in range(num_workers)
|
||||
]
|
||||
results = [future.result() for future in as_completed(futures)]
|
||||
|
||||
# Check if we got duplicate ports (race condition)
|
||||
unique_ports = set(results)
|
||||
duplicates_found = len(results) != len(unique_ports)
|
||||
|
||||
print(
|
||||
f'Without locking - Total ports: {len(results)}, Unique: {len(unique_ports)}'
|
||||
)
|
||||
print(f'Ports allocated: {results}')
|
||||
print(f'Race condition detected: {duplicates_found}')
|
||||
|
||||
# This test demonstrates the problem exists without locking
|
||||
# In a real race condition scenario, we might get duplicates
|
||||
# But since the race window is small, we'll just verify the test runs
|
||||
assert len(results) == num_workers
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test = TestPortLockingFix()
|
||||
test.test_port_lock_prevents_duplicate_allocation()
|
||||
test.test_port_lock_basic_functionality()
|
||||
test.test_port_lock_context_manager()
|
||||
test.test_concurrent_port_allocation_stress_test()
|
||||
test.test_port_allocation_without_locking_shows_race_condition()
|
||||
print('All tests passed!')
|
||||
@@ -1,15 +1,8 @@
|
||||
"""Tests for system stats utilities."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import psutil
|
||||
|
||||
from openhands.runtime.utils.system_stats import (
|
||||
get_system_info,
|
||||
get_system_stats,
|
||||
update_last_execution_time,
|
||||
)
|
||||
from openhands.runtime.utils.system_stats import get_system_stats
|
||||
|
||||
|
||||
def test_get_system_stats():
|
||||
@@ -65,96 +58,3 @@ def test_get_system_stats_stability():
|
||||
stats = get_system_stats()
|
||||
assert isinstance(stats, dict)
|
||||
assert stats['cpu_percent'] >= 0
|
||||
|
||||
|
||||
def test_get_system_info():
|
||||
"""Test that get_system_info returns valid system information."""
|
||||
with patch(
|
||||
'openhands.runtime.utils.system_stats.get_system_stats'
|
||||
) as mock_get_stats:
|
||||
mock_get_stats.return_value = {'cpu_percent': 10.0}
|
||||
|
||||
info = get_system_info()
|
||||
|
||||
# Test structure
|
||||
assert isinstance(info, dict)
|
||||
assert set(info.keys()) == {'uptime', 'idle_time', 'resources'}
|
||||
|
||||
# Test values
|
||||
assert isinstance(info['uptime'], float)
|
||||
assert isinstance(info['idle_time'], float)
|
||||
assert info['uptime'] > 0
|
||||
assert info['idle_time'] >= 0
|
||||
assert info['resources'] == {'cpu_percent': 10.0}
|
||||
|
||||
# Verify get_system_stats was called
|
||||
mock_get_stats.assert_called_once()
|
||||
|
||||
|
||||
def test_update_last_execution_time():
|
||||
"""Test that update_last_execution_time updates the last execution time."""
|
||||
# Get initial system info
|
||||
initial_info = get_system_info()
|
||||
initial_idle_time = initial_info['idle_time']
|
||||
|
||||
# Wait a bit to ensure time difference
|
||||
time.sleep(0.1)
|
||||
|
||||
# Update last execution time
|
||||
update_last_execution_time()
|
||||
|
||||
# Get updated system info
|
||||
updated_info = get_system_info()
|
||||
updated_idle_time = updated_info['idle_time']
|
||||
|
||||
# The idle time should be reset (close to zero)
|
||||
assert updated_idle_time < initial_idle_time
|
||||
assert updated_idle_time < 0.1 # Should be very small
|
||||
|
||||
|
||||
def test_idle_time_increases_without_updates():
|
||||
"""Test that idle_time increases when no updates are made."""
|
||||
# Update last execution time to reset idle time
|
||||
update_last_execution_time()
|
||||
|
||||
# Get initial system info
|
||||
initial_info = get_system_info()
|
||||
initial_idle_time = initial_info['idle_time']
|
||||
|
||||
# Wait a bit
|
||||
time.sleep(0.2)
|
||||
|
||||
# Get updated system info without calling update_last_execution_time
|
||||
updated_info = get_system_info()
|
||||
updated_idle_time = updated_info['idle_time']
|
||||
|
||||
# The idle time should have increased
|
||||
assert updated_idle_time > initial_idle_time
|
||||
assert updated_idle_time >= 0.2 # Should be at least the sleep time
|
||||
|
||||
|
||||
@patch('time.time')
|
||||
def test_idle_time_calculation(mock_time):
|
||||
"""Test that idle_time is calculated correctly."""
|
||||
# Mock time.time() to return controlled values
|
||||
mock_time.side_effect = [
|
||||
100.0, # Initial _start_time
|
||||
100.0, # Initial _last_execution_time
|
||||
110.0, # Current time in get_system_info
|
||||
]
|
||||
|
||||
# Import the module again to reset the global variables with our mocked time
|
||||
import importlib
|
||||
|
||||
import openhands.runtime.utils.system_stats
|
||||
|
||||
importlib.reload(openhands.runtime.utils.system_stats)
|
||||
|
||||
# Get system info
|
||||
from openhands.runtime.utils.system_stats import get_system_info
|
||||
|
||||
info = get_system_info()
|
||||
|
||||
# Verify idle_time calculation
|
||||
assert info['uptime'] == 10.0 # 110 - 100
|
||||
assert info['idle_time'] == 10.0 # 110 - 100
|
||||
|
||||
@@ -102,7 +102,7 @@ def mock_repo_microagent():
|
||||
]
|
||||
),
|
||||
),
|
||||
source='.openhands/microagents/test_repo_agent.md',
|
||||
source='test_source',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
@@ -128,7 +128,7 @@ def mock_knowledge_microagent():
|
||||
]
|
||||
),
|
||||
),
|
||||
source='.openhands/microagents/test_knowledge_agent.md',
|
||||
source='test_source',
|
||||
type=MicroagentType.KNOWLEDGE,
|
||||
triggers=['test', 'knowledge', 'search'],
|
||||
)
|
||||
@@ -283,72 +283,7 @@ class TestGetRepositoryMicroagents:
|
||||
mock_result.stderr = ''
|
||||
mock_subprocess_run.return_value = mock_result
|
||||
|
||||
# Create mock microagents with proper absolute paths
|
||||
repo_agent_with_path = RepoMicroagent(
|
||||
name='test_repo_agent',
|
||||
content='This is a test repository microagent for testing purposes.',
|
||||
metadata=MicroagentMetadata(
|
||||
name='test_repo_agent',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
inputs=[
|
||||
InputMetadata(
|
||||
name='query',
|
||||
type='str',
|
||||
description='Search query for the repository',
|
||||
)
|
||||
],
|
||||
mcp_tools=MCPConfig(
|
||||
stdio_servers=[
|
||||
MCPStdioServerConfig(name='git', command='git'),
|
||||
MCPStdioServerConfig(name='file_editor', command='editor'),
|
||||
]
|
||||
),
|
||||
),
|
||||
source=str(
|
||||
Path(temp_microagents_dir)
|
||||
/ 'repo'
|
||||
/ '.openhands'
|
||||
/ 'microagents'
|
||||
/ 'test_repo_agent.md'
|
||||
),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
knowledge_agent_with_path = KnowledgeMicroagent(
|
||||
name='test_knowledge_agent',
|
||||
content='This is a test knowledge microagent for testing purposes.',
|
||||
metadata=MicroagentMetadata(
|
||||
name='test_knowledge_agent',
|
||||
type=MicroagentType.KNOWLEDGE,
|
||||
inputs=[
|
||||
InputMetadata(
|
||||
name='topic', type='str', description='Topic to search for'
|
||||
)
|
||||
],
|
||||
mcp_tools=MCPConfig(
|
||||
stdio_servers=[
|
||||
MCPStdioServerConfig(name='search', command='search'),
|
||||
MCPStdioServerConfig(name='fetch', command='fetch'),
|
||||
]
|
||||
),
|
||||
),
|
||||
source=str(
|
||||
Path(temp_microagents_dir)
|
||||
/ 'repo'
|
||||
/ '.openhands'
|
||||
/ 'microagents'
|
||||
/ 'test_knowledge_agent.md'
|
||||
),
|
||||
type=MicroagentType.KNOWLEDGE,
|
||||
triggers=['test', 'knowledge', 'search'],
|
||||
)
|
||||
|
||||
mock_microagents_data_with_paths = (
|
||||
{'test_repo_agent': repo_agent_with_path},
|
||||
{'test_knowledge_agent': knowledge_agent_with_path},
|
||||
)
|
||||
|
||||
mock_load_microagents.return_value = mock_microagents_data_with_paths
|
||||
mock_load_microagents.return_value = mock_microagents_data
|
||||
mock_mkdtemp.return_value = temp_microagents_dir
|
||||
|
||||
# Execute test
|
||||
@@ -373,8 +308,6 @@ class TestGetRepositoryMicroagents:
|
||||
assert 'created_at' in repo_agent
|
||||
assert 'git_provider' in repo_agent
|
||||
assert repo_agent['git_provider'] == 'github'
|
||||
assert 'path' in repo_agent
|
||||
assert repo_agent['path'] == '.openhands/microagents/test_repo_agent.md'
|
||||
|
||||
# Check knowledge microagent
|
||||
knowledge_agent = next(m for m in data if m['name'] == 'test_knowledge_agent')
|
||||
@@ -390,10 +323,6 @@ class TestGetRepositoryMicroagents:
|
||||
assert 'created_at' in knowledge_agent
|
||||
assert 'git_provider' in knowledge_agent
|
||||
assert knowledge_agent['git_provider'] == 'github'
|
||||
assert 'path' in knowledge_agent
|
||||
assert (
|
||||
knowledge_agent['path'] == '.openhands/microagents/test_knowledge_agent.md'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.server.routes.git.ProviderHandler')
|
||||
@@ -626,38 +555,8 @@ class TestGetRepositoryMicroagents:
|
||||
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
|
||||
microagents_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create mock microagents with proper absolute paths
|
||||
repo_agent_with_path = RepoMicroagent(
|
||||
name='test_repo_agent',
|
||||
content='This is a test repository microagent for testing purposes.',
|
||||
metadata=MicroagentMetadata(
|
||||
name='test_repo_agent',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
inputs=[
|
||||
InputMetadata(
|
||||
name='query',
|
||||
type='str',
|
||||
description='Search query for the repository',
|
||||
)
|
||||
],
|
||||
mcp_tools=MCPConfig(
|
||||
stdio_servers=[
|
||||
MCPStdioServerConfig(name='git', command='git'),
|
||||
MCPStdioServerConfig(name='file_editor', command='editor'),
|
||||
]
|
||||
),
|
||||
),
|
||||
source=str(
|
||||
Path(temp_dir)
|
||||
/ 'repo'
|
||||
/ '.openhands'
|
||||
/ 'microagents'
|
||||
/ 'test_repo_agent.md'
|
||||
),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
|
||||
# Mock load_microagents_from_dir
|
||||
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
|
||||
mock_knowledge_agents = {}
|
||||
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
|
||||
mock_mkdtemp.return_value = temp_dir
|
||||
@@ -675,8 +574,6 @@ class TestGetRepositoryMicroagents:
|
||||
assert 'created_at' in data[0]
|
||||
assert 'git_provider' in data[0]
|
||||
assert data[0]['git_provider'] == 'github'
|
||||
assert 'path' in data[0]
|
||||
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@@ -737,38 +634,8 @@ class TestGetRepositoryMicroagents:
|
||||
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
|
||||
microagents_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create mock microagents with proper absolute paths
|
||||
repo_agent_with_path = RepoMicroagent(
|
||||
name='test_repo_agent',
|
||||
content='This is a test repository microagent for testing purposes.',
|
||||
metadata=MicroagentMetadata(
|
||||
name='test_repo_agent',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
inputs=[
|
||||
InputMetadata(
|
||||
name='query',
|
||||
type='str',
|
||||
description='Search query for the repository',
|
||||
)
|
||||
],
|
||||
mcp_tools=MCPConfig(
|
||||
stdio_servers=[
|
||||
MCPStdioServerConfig(name='git', command='git'),
|
||||
MCPStdioServerConfig(name='file_editor', command='editor'),
|
||||
]
|
||||
),
|
||||
),
|
||||
source=str(
|
||||
Path(temp_dir)
|
||||
/ 'repo'
|
||||
/ '.openhands'
|
||||
/ 'microagents'
|
||||
/ 'test_repo_agent.md'
|
||||
),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
|
||||
# Mock load_microagents_from_dir
|
||||
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
|
||||
mock_knowledge_agents = {}
|
||||
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
|
||||
mock_mkdtemp.return_value = temp_dir
|
||||
@@ -785,8 +652,6 @@ class TestGetRepositoryMicroagents:
|
||||
assert 'created_at' in data[0]
|
||||
assert 'git_provider' in data[0]
|
||||
assert data[0]['git_provider'] == 'github'
|
||||
assert 'path' in data[0]
|
||||
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@@ -883,6 +748,20 @@ class TestGetRepositoryMicroagents:
|
||||
lambda: mock_provider_tokens
|
||||
)
|
||||
|
||||
# Create microagent without MCP tools
|
||||
repo_microagent = RepoMicroagent(
|
||||
name='simple_agent',
|
||||
content='Simple agent without MCP tools',
|
||||
metadata=MicroagentMetadata(
|
||||
name='simple_agent',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
inputs=[],
|
||||
mcp_tools=None,
|
||||
),
|
||||
source='test_source',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
mock_provider_handler = MagicMock()
|
||||
mock_repository = Repository(
|
||||
id='123456',
|
||||
@@ -910,26 +789,6 @@ class TestGetRepositoryMicroagents:
|
||||
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
|
||||
microagents_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create microagent without MCP tools
|
||||
repo_microagent = RepoMicroagent(
|
||||
name='simple_agent',
|
||||
content='Simple agent without MCP tools',
|
||||
metadata=MicroagentMetadata(
|
||||
name='simple_agent',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
inputs=[],
|
||||
mcp_tools=None,
|
||||
),
|
||||
source=str(
|
||||
Path(temp_dir)
|
||||
/ 'repo'
|
||||
/ '.openhands'
|
||||
/ 'microagents'
|
||||
/ 'simple_agent.md'
|
||||
),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
# Mock load_microagents_from_dir
|
||||
mock_repo_agents = {'simple_agent': repo_microagent}
|
||||
mock_knowledge_agents = {}
|
||||
@@ -949,225 +808,5 @@ class TestGetRepositoryMicroagents:
|
||||
assert 'created_at' in data[0]
|
||||
assert 'git_provider' in data[0]
|
||||
assert data[0]['git_provider'] == 'github'
|
||||
assert 'path' in data[0]
|
||||
assert data[0]['path'] == '.openhands/microagents/simple_agent.md'
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'openhands.server.routes.git._get_file_creation_time',
|
||||
return_value=datetime.now(),
|
||||
)
|
||||
@patch('openhands.server.routes.git.tempfile.mkdtemp')
|
||||
@patch('openhands.server.routes.git.load_microagents_from_dir')
|
||||
@patch('openhands.server.routes.git.subprocess.run')
|
||||
@patch('openhands.server.routes.git.ProviderHandler')
|
||||
async def test_get_microagents_path_field_variations(
|
||||
self,
|
||||
mock_provider_handler_class,
|
||||
mock_subprocess_run,
|
||||
mock_load_microagents,
|
||||
mock_mkdtemp,
|
||||
mock_get_file_creation_time,
|
||||
test_client,
|
||||
mock_provider_tokens,
|
||||
):
|
||||
"""Test path field with different microagent file locations and structures."""
|
||||
# Setup mocks
|
||||
test_client.app.dependency_overrides[get_provider_tokens] = (
|
||||
lambda: mock_provider_tokens
|
||||
)
|
||||
|
||||
mock_provider_handler = MagicMock()
|
||||
mock_repository = Repository(
|
||||
id='123456',
|
||||
full_name='test/repo',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
stargazers_count=100,
|
||||
)
|
||||
mock_provider_handler.verify_repo_provider = AsyncMock(
|
||||
return_value=mock_repository
|
||||
)
|
||||
mock_provider_handler.get_authenticated_git_url = AsyncMock(
|
||||
return_value='https://ghp_test_token@github.com/test/repo.git'
|
||||
)
|
||||
mock_provider_handler_class.return_value = mock_provider_handler
|
||||
|
||||
# Mock subprocess.run for successful clone
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stderr = ''
|
||||
mock_subprocess_run.return_value = mock_result
|
||||
|
||||
# Create temporary directory with microagents
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
|
||||
microagents_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create microagents with different source paths
|
||||
repo_microagent_deep = RepoMicroagent(
|
||||
name='deep_agent',
|
||||
content='Agent in nested directory',
|
||||
metadata=MicroagentMetadata(
|
||||
name='deep_agent',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
inputs=[],
|
||||
mcp_tools=None,
|
||||
),
|
||||
source=str(
|
||||
Path(temp_dir)
|
||||
/ 'repo'
|
||||
/ '.openhands'
|
||||
/ 'microagents'
|
||||
/ 'nested'
|
||||
/ 'deep_agent.md'
|
||||
),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
knowledge_microagent_root = KnowledgeMicroagent(
|
||||
name='root_agent',
|
||||
content='Agent in root microagents directory',
|
||||
metadata=MicroagentMetadata(
|
||||
name='root_agent',
|
||||
type=MicroagentType.KNOWLEDGE,
|
||||
inputs=[],
|
||||
mcp_tools=None,
|
||||
),
|
||||
source=str(
|
||||
Path(temp_dir) / 'repo' / '.openhands' / 'microagents' / 'root_agent.md'
|
||||
),
|
||||
type=MicroagentType.KNOWLEDGE,
|
||||
triggers=[],
|
||||
)
|
||||
|
||||
# Mock load_microagents_from_dir
|
||||
mock_repo_agents = {'deep_agent': repo_microagent_deep}
|
||||
mock_knowledge_agents = {'root_agent': knowledge_microagent_root}
|
||||
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
|
||||
mock_mkdtemp.return_value = temp_dir
|
||||
|
||||
try:
|
||||
# Execute test
|
||||
response = test_client.get('/api/user/repository/test/repo/microagents')
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
|
||||
# Check repo microagent with nested path
|
||||
repo_agent = next(m for m in data if m['name'] == 'deep_agent')
|
||||
assert repo_agent['type'] == 'repo'
|
||||
assert 'path' in repo_agent
|
||||
assert repo_agent['path'] == '.openhands/microagents/nested/deep_agent.md'
|
||||
|
||||
# Check knowledge microagent with root path
|
||||
knowledge_agent = next(m for m in data if m['name'] == 'root_agent')
|
||||
assert knowledge_agent['type'] == 'knowledge'
|
||||
assert 'path' in knowledge_agent
|
||||
assert knowledge_agent['path'] == '.openhands/microagents/root_agent.md'
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'openhands.server.routes.git._get_file_creation_time',
|
||||
return_value=datetime.now(),
|
||||
)
|
||||
@patch('openhands.server.routes.git.tempfile.mkdtemp')
|
||||
@patch('openhands.server.routes.git.load_microagents_from_dir')
|
||||
@patch('openhands.server.routes.git.subprocess.run')
|
||||
@patch('openhands.server.routes.git.ProviderHandler')
|
||||
async def test_get_microagents_path_field_gitlab_structure(
|
||||
self,
|
||||
mock_provider_handler_class,
|
||||
mock_subprocess_run,
|
||||
mock_load_microagents,
|
||||
mock_mkdtemp,
|
||||
mock_get_file_creation_time,
|
||||
test_client,
|
||||
mock_provider_tokens,
|
||||
):
|
||||
"""Test path field with GitLab repository structure (openhands-config)."""
|
||||
# Setup mocks with GitLab provider
|
||||
provider_tokens = MappingProxyType(
|
||||
{
|
||||
ProviderType.GITLAB: ProviderToken(
|
||||
token=SecretStr('glpat_test_token'), host='gitlab.com'
|
||||
)
|
||||
}
|
||||
)
|
||||
test_client.app.dependency_overrides[get_provider_tokens] = (
|
||||
lambda: provider_tokens
|
||||
)
|
||||
|
||||
mock_provider_handler = MagicMock()
|
||||
mock_repository = Repository(
|
||||
id='123456',
|
||||
full_name='test/openhands-config',
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=True,
|
||||
stargazers_count=100,
|
||||
)
|
||||
mock_provider_handler.verify_repo_provider = AsyncMock(
|
||||
return_value=mock_repository
|
||||
)
|
||||
mock_provider_handler.get_authenticated_git_url = AsyncMock(
|
||||
return_value='https://glpat_test_token@gitlab.com/test/openhands-config.git'
|
||||
)
|
||||
mock_provider_handler_class.return_value = mock_provider_handler
|
||||
|
||||
# Mock subprocess.run for successful clone
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stderr = ''
|
||||
mock_subprocess_run.return_value = mock_result
|
||||
|
||||
# Create temporary directory with GitLab structure
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
microagents_dir = Path(temp_dir) / 'repo' / 'microagents'
|
||||
microagents_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create microagent for GitLab structure
|
||||
repo_microagent = RepoMicroagent(
|
||||
name='gitlab_agent',
|
||||
content='Agent in GitLab repository',
|
||||
metadata=MicroagentMetadata(
|
||||
name='gitlab_agent',
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
inputs=[],
|
||||
mcp_tools=None,
|
||||
),
|
||||
source=str(Path(temp_dir) / 'repo' / 'microagents' / 'gitlab_agent.md'),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
# Mock load_microagents_from_dir
|
||||
mock_repo_agents = {'gitlab_agent': repo_microagent}
|
||||
mock_knowledge_agents = {}
|
||||
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
|
||||
mock_mkdtemp.return_value = temp_dir
|
||||
|
||||
try:
|
||||
# Execute test
|
||||
response = test_client.get(
|
||||
'/api/user/repository/test/openhands-config/microagents'
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]['name'] == 'gitlab_agent'
|
||||
assert data[0]['type'] == 'repo'
|
||||
assert 'path' in data[0]
|
||||
assert data[0]['path'] == 'microagents/gitlab_agent.md'
|
||||
assert 'git_provider' in data[0]
|
||||
assert data[0]['git_provider'] == 'gitlab'
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
Reference in New Issue
Block a user