Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Persistent browser sessions #1391

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0939078
Set close_browser_on_completion False
satansdeer Dec 11, 2024
e2fb418
Add persistent sessions manager + test
satansdeer Dec 11, 2024
8603686
Define route to get persistent session; Add route test
satansdeer Dec 15, 2024
3bb3ecd
Test sessions list endpoint
satansdeer Dec 15, 2024
ba935a3
Add session creation endpoint
satansdeer Dec 15, 2024
d699a29
Define test commands
satansdeer Dec 15, 2024
5a63a7d
Make it possible to set and use dev variables
satansdeer Dec 15, 2024
848efd0
Add browser session id to workflow runs
satansdeer Dec 15, 2024
773a6d1
Add browser_session_id to context
satansdeer Dec 16, 2024
e40d018
Use persistent browser session if present
satansdeer Dec 16, 2024
ca59269
Pass browser_session_id through async executor
satansdeer Dec 16, 2024
a457428
Handle browser context close event
satansdeer Dec 16, 2024
95efc67
Test browser close event callback
satansdeer Dec 16, 2024
dac6002
Navigate to new url when reusing browser sessions
satansdeer Dec 17, 2024
33c15ac
Add persistent browser session model
satansdeer Dec 17, 2024
02f38da
Add alembic migration for persistent browser sessions
satansdeer Dec 17, 2024
ff2d574
Run pre-commit checks
satansdeer Dec 17, 2024
8ad623f
Add deleted at to persistent browser sessions
satansdeer Dec 17, 2024
874317c
Add db functions for persistent sessions
satansdeer Dec 17, 2024
f7d0dc8
Add db functions for persistent sessions
satansdeer Dec 17, 2024
7de53bd
Join persistent browser sessions migrations into one
satansdeer Dec 17, 2024
a66de02
Use db module in the browser sessions manager
satansdeer Dec 17, 2024
552faf6
Await ids list for browser sessions
satansdeer Dec 17, 2024
dde3dd5
Store persistent browser sessions data in the db
satansdeer Dec 18, 2024
f4e9020
Implement session creation
satansdeer Dec 18, 2024
f39b055
Get existing browser sessions
satansdeer Dec 18, 2024
32713b2
Implement getting browser session
satansdeer Dec 18, 2024
dac0a69
Implement deleting browser sessions
satansdeer Dec 18, 2024
7c9ea5c
Add log about marking persistent browser session deleted
satansdeer Dec 18, 2024
6852543
Use persistent browser session when running workflow
satansdeer Dec 18, 2024
638c92e
Implement occupy and release methods for persistent browser sessions
satansdeer Dec 18, 2024
69c619e
Add browser session id to task execution
satansdeer Dec 18, 2024
4ec795c
Add browser session id to task launch parameters
satansdeer Dec 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""introduce persistent browser sessions

Revision ID: 282b0548d443
Revises: 411dd89f3df9
Create Date: 2024-12-17 18:41:30.400052+00:00

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "282b0548d443"
down_revision: Union[str, None] = "411dd89f3df9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"persistent_browser_sessions",
sa.Column("persistent_browser_session_id", sa.String(), nullable=False),
sa.Column("organization_id", sa.String(), nullable=False),
sa.Column("runnable_type", sa.String(), nullable=False),
sa.Column("runnable_id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("modified_at", sa.DateTime(), nullable=False),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.organization_id"],
),
sa.PrimaryKeyConstraint("persistent_browser_session_id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("persistent_browser_sessions")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""make runnable_type and runnable_id nullable

Revision ID: 065aca30d46d
Revises: 282b0548d443
Create Date: 2024-12-18 06:30:46.558730+00:00

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '065aca30d46d'
down_revision: Union[str, None] = '282b0548d443'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('persistent_browser_sessions', 'runnable_type',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('persistent_browser_sessions', 'runnable_id',
existing_type=sa.VARCHAR(),
nullable=True)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('persistent_browser_sessions', 'runnable_id',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('persistent_browser_sessions', 'runnable_type',
existing_type=sa.VARCHAR(),
nullable=False)
# ### end Alembic commands ###
13 changes: 13 additions & 0 deletions run_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash

source "$(poetry env info --path)/bin/activate"
poetry install

# Run pytest with various options
poetry run pytest \
-v \
--asyncio-mode=auto \
"$@"

# Exit with the pytest exit code
exit $?
148 changes: 148 additions & 0 deletions skyvern-frontend/src/initDevCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { apiBaseUrl, envCredential } from "./util/env";

export type DevCommands = {
createBrowserSession: () => Promise<void>;
listBrowserSessions: () => Promise<void>;
getBrowserSession: (sessionId: string) => Promise<void>;
closeBrowserSessions: () => Promise<void>;
setValue: (key: string, value: unknown) => void;
getValue: (key: string) => unknown;
};

export function initDevCommands() {
if (!envCredential) {
console.warn("envCredential environment variable was not set");
return;
}

async function createBrowserSession() {
try {
const response = await fetch(`${apiBaseUrl}/browser_sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": envCredential!,
},
credentials: "include",
});

if (!response.ok) {
throw new Error(
`Failed to create browser session: ${response.statusText}`,
);
}

const data = await response.json();
console.log("Created browser session:", data);
return undefined;
} catch (error) {
console.error("Error creating browser session:", error);
throw error;
}
}

async function listBrowserSessions() {
try {
const response = await fetch(`${apiBaseUrl}/browser_sessions`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-API-Key": envCredential!,
},
credentials: "include",
});

if (!response.ok) {
throw new Error(
`Failed to list browser sessions: ${response.statusText}`,
);
}

const data = await response.json();
console.log("Browser sessions:", data);
return undefined;
} catch (error) {
console.error("Error listing browser sessions:", error);
throw error;
}
}

async function getBrowserSession(sessionId: string) {
try {
const response = await fetch(
`${apiBaseUrl}/browser_sessions/${sessionId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"X-API-Key": envCredential!,
},
credentials: "include",
},
);

if (!response.ok) {
throw new Error(
`Failed to get browser session: ${response.statusText}`,
);
}

const data = await response.json();
console.log("Browser session:", data);
return undefined;
} catch (error) {
console.error("Error getting browser session:", error);
throw error;
}
}

async function closeBrowserSessions() {
try {
const response = await fetch(`${apiBaseUrl}/browser_sessions/close`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": envCredential!,
},
credentials: "include",
});

if (!response.ok) {
throw new Error(
`Failed to close browser sessions: ${response.statusText}`,
);
}

const data = await response.json();
console.log("Browser sessions:", data);
return undefined;
} catch (error) {
console.error("Error closing browser sessions:", error);
throw error;
}
}

function setValue(key: string, value: unknown) {
localStorage.setItem(key, JSON.stringify(value));
}

function getValue(key: string) {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : null;
}

(window as unknown as Window).devCommands = {
createBrowserSession,
listBrowserSessions,
getBrowserSession,
closeBrowserSessions,
setValue,
getValue,
};

console.log("Dev commands initialized. Available commands:");
console.log("- window.devCommands.createBrowserSession()");
console.log("- window.devCommands.listBrowserSessions()");
console.log("- window.devCommands.getBrowserSession(sessionId)");
console.log("- window.devCommands.closeBrowserSessions()");
}
4 changes: 4 additions & 0 deletions skyvern-frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";

import { initDevCommands } from "./initDevCommands.ts";

initDevCommands();

ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
Expand Down
7 changes: 7 additions & 0 deletions skyvern-frontend/src/routes/tasks/create/SavedTaskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ function SavedTaskForm({ initialValues }: Props) {
})
.then(() => {
const taskRequest = createTaskRequestObject(formValues);

// if (window.devCommands?.getValue("browserSessionId")) {
// taskRequest.browser_session_id = window.devCommands.getValue(
// "browserSessionId",
// ) as unknown as string;
// }

const includeOverrideHeader =
formValues.maxStepsOverride !== null &&
formValues.maxStepsOverride !== MAX_STEPS_DEFAULT;
Expand Down
7 changes: 7 additions & 0 deletions skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type RunWorkflowRequestBody = {
data: Record<string, unknown>; // workflow parameters and values
proxy_location: ProxyLocation | null;
webhook_callback_url?: string | null;
browser_session_id?: string | null;
};

function getRunWorkflowRequestBody(
Expand All @@ -88,6 +89,12 @@ function getRunWorkflowRequestBody(
proxy_location: proxyLocation,
};

if (window.devCommands?.getValue("browserSessionId")) {
body.browser_session_id = window.devCommands.getValue(
"browserSessionId",
) as unknown as string;
}

if (webhookCallbackUrl) {
body.webhook_callback_url = webhookCallbackUrl;
}
Expand Down
9 changes: 9 additions & 0 deletions skyvern-frontend/src/window.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { DevCommands } from "./initDevCommands";

export {};

declare global {
interface Window {
devCommands: DevCommands;
}
}
8 changes: 8 additions & 0 deletions skyvern/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,11 @@ def __init__(self, element_id: str):
super().__init__(
f"Select on the dropdown container instead of the option, try again with another element. element_id={element_id}"
)


class BrowserSessionNotFound(SkyvernHTTPException):
def __init__(self, browser_session_id: str):
super().__init__(
f"Browser session not found. browser_session_id={browser_session_id}",
status_code=status.HTTP_404_NOT_FOUND,
)
19 changes: 16 additions & 3 deletions skyvern/forge/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ async def create_task_and_step_from_block(
workflow_run_context: WorkflowRunContext,
task_order: int,
task_retry: int,
browser_session_id: str | None = None,
) -> tuple[Task, Step]:
task_block_parameters = task_block.parameters
navigation_payload = {}
Expand Down Expand Up @@ -244,8 +245,10 @@ async def execute_step(
api_key: str | None = None,
close_browser_on_completion: bool = True,
task_block: BaseTaskBlock | None = None,
browser_session_id: str | None = None,
) -> Tuple[Step, DetailedAgentStepOutput | None, Step | None]:
workflow_run: WorkflowRun | None = None

if task.workflow_run_id:
workflow_run = await app.DATABASE.get_workflow_run(workflow_run_id=task.workflow_run_id)
if workflow_run and workflow_run.status == WorkflowRunStatus.canceled:
Expand Down Expand Up @@ -286,6 +289,7 @@ async def execute_step(
last_step=step,
api_key=api_key,
need_call_webhook=True,
browser_session_id=browser_session_id,
)
return step, None, None

Expand Down Expand Up @@ -1099,14 +1103,23 @@ async def record_artifacts_after_action(self, task: Task, step: Step, browser_st
)

async def _initialize_execution_state(
self, task: Task, step: Step, workflow_run: WorkflowRun | None = None
self,
task: Task,
step: Step,
workflow_run: WorkflowRun | None = None,
browser_session_id: str | None = None,
) -> tuple[Step, BrowserState, DetailedAgentStepOutput]:
if workflow_run:
browser_state = await app.BROWSER_MANAGER.get_or_create_for_workflow_run(
workflow_run=workflow_run, url=task.url
workflow_run=workflow_run,
url=task.url,
browser_session_id=browser_session_id,
)
else:
browser_state = await app.BROWSER_MANAGER.get_or_create_for_task(task)
browser_state = await app.BROWSER_MANAGER.get_or_create_for_task(
task=task,
browser_session_id=browser_session_id,
)
# Initialize video artifact for the task here, afterwards it'll only get updated
if browser_state and browser_state.browser_artifacts:
video_artifacts = await app.BROWSER_MANAGER.get_video_artifacts(
Expand Down
2 changes: 2 additions & 0 deletions skyvern/forge/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from skyvern.forge.sdk.workflow.context_manager import WorkflowContextManager
from skyvern.forge.sdk.workflow.service import WorkflowService
from skyvern.webeye.browser_manager import BrowserManager
from skyvern.webeye.persistent_sessions_manager import PersistentSessionsManager
from skyvern.webeye.scraper.scraper import ScrapeExcludeFunc

SETTINGS_MANAGER = SettingsManager.get_settings()
Expand All @@ -37,6 +38,7 @@
WORKFLOW_CONTEXT_MANAGER = WorkflowContextManager()
WORKFLOW_SERVICE = WorkflowService()
AGENT_FUNCTION = AgentFunction()
PERSISTENT_SESSIONS_MANAGER = PersistentSessionsManager(database=DATABASE)
scrape_exclude: ScrapeExcludeFunc | None = None
authentication_function: Callable[[str], Awaitable[Organization]] | None = None
setup_api_app: Callable[[FastAPI], None] | None = None
Expand Down
1 change: 1 addition & 0 deletions skyvern/forge/sdk/core/skyvern_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class SkyvernContext:
workflow_run_id: str | None = None
max_steps_override: int | None = None
tz_info: ZoneInfo | None = None
browser_session_id: str | None = None
totp_codes: dict[str, str | None] = field(default_factory=dict)

def __repr__(self) -> str:
Expand Down
Loading