Compare commits
242 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b0fce76e7 | ||
|
|
24863e8505 | ||
|
|
ad1b50cf58 | ||
|
|
806977ece8 | ||
|
|
962acb3cdd | ||
|
|
4d2aeda083 | ||
|
|
38b178f22f | ||
|
|
9b18e40d45 | ||
|
|
0ee505b24b | ||
|
|
295d1935f5 | ||
|
|
f2acc92939 | ||
|
|
c269e36c16 | ||
|
|
55709c945e | ||
|
|
9e49dff964 | ||
|
|
f09632c51c | ||
|
|
95e57cf43d | ||
|
|
b4e6c8f1cc | ||
|
|
e9ef9529ca | ||
|
|
3aaffb667d | ||
|
|
59fd1982d8 | ||
|
|
e174afd63f | ||
|
|
edf38e68fc | ||
|
|
bf3624d788 | ||
|
|
d79359d55c | ||
|
|
7b4dd7e7c2 | ||
|
|
7120df409d | ||
|
|
97da02c149 | ||
|
|
65d28b1666 | ||
|
|
33ee848e01 | ||
|
|
3e260f21bf | ||
|
|
6a5226d197 | ||
|
|
bb7fe7158f | ||
|
|
8b78d8e8f1 | ||
|
|
633b65d33d | ||
|
|
26f73ec044 | ||
|
|
7681a7cd2b | ||
|
|
ac7ffaf6eb | ||
|
|
d72b6a1ba0 | ||
|
|
400fd43d7b | ||
|
|
c2038aaafc | ||
|
|
ac01aca99c | ||
|
|
265679e9dd | ||
|
|
2452bf60e1 | ||
|
|
777e842c39 | ||
|
|
337c002595 | ||
|
|
f339ff76da | ||
|
|
a3ce97384c | ||
|
|
1eb2e64f03 | ||
|
|
78fa30ed11 | ||
|
|
bee2b836ce | ||
|
|
323adca6a1 | ||
|
|
bff497e2e5 | ||
|
|
9949fb0ded | ||
|
|
87303f70b1 | ||
|
|
fa5bef92f3 | ||
|
|
abaf77f41b | ||
|
|
29a100956f | ||
|
|
cedd5a9c1c | ||
|
|
30674711a7 | ||
|
|
3fc353fe04 | ||
|
|
c78637ae87 | ||
|
|
b7c1383f81 | ||
|
|
95d1e966fa | ||
|
|
5142883ed6 | ||
|
|
632b8ec501 | ||
|
|
d0094d3568 | ||
|
|
147eea686f | ||
|
|
caf7420309 | ||
|
|
e7a57dd653 | ||
|
|
ab94965186 | ||
|
|
ee0964b43a | ||
|
|
d0614586f2 | ||
|
|
f939ec8d32 | ||
|
|
f920d193eb | ||
|
|
e116aaf35c | ||
|
|
e2edbd9aea | ||
|
|
90e29f9fc9 | ||
|
|
c14b6cb95f | ||
|
|
368d4a5453 | ||
|
|
4c4db2a1c3 | ||
|
|
f22243ea3f | ||
|
|
45505ef3e1 | ||
|
|
ef9476e8fa | ||
|
|
298ab5550c | ||
|
|
754751559a | ||
|
|
75df48f09e | ||
|
|
fe5f183952 | ||
|
|
af2a0ffcbd | ||
|
|
3c2c44c11a | ||
|
|
9089feadc7 | ||
|
|
ba2ec32e15 | ||
|
|
d723de621d | ||
|
|
4d06850f1c | ||
|
|
fdfaf3bbf7 | ||
|
|
d71ea50d9a | ||
|
|
bcc765f15c | ||
|
|
453db78251 | ||
|
|
2bd0c07665 | ||
|
|
f4bdd49b0a | ||
|
|
43cf66c145 | ||
|
|
7bd572c230 | ||
|
|
552f0dd437 | ||
|
|
4dc6094473 | ||
|
|
7eecd12f82 | ||
|
|
8f61fe84ea | ||
|
|
5106524ed7 | ||
|
|
eeec9f38b9 | ||
|
|
ec773e02d6 | ||
|
|
f2f3399e67 | ||
|
|
8ab0db16d1 | ||
|
|
cef0be8d3b | ||
|
|
f78ad441fc | ||
|
|
cc485dfedb | ||
|
|
ade173ea11 | ||
|
|
b38764df3b | ||
|
|
a050833653 | ||
|
|
23f94b93b7 | ||
|
|
e82ad57df7 | ||
|
|
3672af57da | ||
|
|
a77a3bbccb | ||
|
|
54bc868931 | ||
|
|
e594525c7e | ||
|
|
b72f4be7e8 | ||
|
|
10d2b44145 | ||
|
|
2d94acf6a2 | ||
|
|
121d323ec2 | ||
|
|
a95bab83a3 | ||
|
|
69ae62db5b | ||
|
|
d36556a6de | ||
|
|
31c333af5d | ||
|
|
1fc396e2c1 | ||
|
|
2d99adc09a | ||
|
|
973f67b0f8 | ||
|
|
7f8f30241e | ||
|
|
a62dbe996f | ||
|
|
ece73f51e4 | ||
|
|
085c718712 | ||
|
|
9842cb5327 | ||
|
|
ac0bfa20b6 | ||
|
|
778257a379 | ||
|
|
56fb4f88bb | ||
|
|
dcb5a24c66 | ||
|
|
86e31de917 | ||
|
|
babe19b186 | ||
|
|
c7fead53ff | ||
|
|
b934c6165d | ||
|
|
234ebcba02 | ||
|
|
b8e789d665 | ||
|
|
6e788520d7 | ||
|
|
7feeefe552 | ||
|
|
7b4dd9ada8 | ||
|
|
6bb409814a | ||
|
|
34d3196c64 | ||
|
|
c1c2f65580 | ||
|
|
4439cabdbd | ||
|
|
36f0d47a77 | ||
|
|
6d63c5508e | ||
|
|
852040d107 | ||
|
|
db3c87b650 | ||
|
|
e79393b600 | ||
|
|
38d160c18a | ||
|
|
0401d0960b | ||
|
|
d10191f6ab | ||
|
|
bb9c183fb3 | ||
|
|
03e23fe9ba | ||
|
|
1c5ec09f9a | ||
|
|
d735f172be | ||
|
|
720d47d754 | ||
|
|
ef22a35461 | ||
|
|
1fea7f8abc | ||
|
|
c49ab35dab | ||
|
|
3536333904 | ||
|
|
e60b983cfe | ||
|
|
226796b113 | ||
|
|
53474e4ce7 | ||
|
|
6fcbbda66e | ||
|
|
42edab6de3 | ||
|
|
ae61830ec5 | ||
|
|
56dc1c51de | ||
|
|
b111a8b95d | ||
|
|
81ab993975 | ||
|
|
07618ceaf9 | ||
|
|
8a118cadfe | ||
|
|
6ebf0c6f24 | ||
|
|
d1cee63485 | ||
|
|
6b3d91e6b6 | ||
|
|
cddad3a3f0 | ||
|
|
cd088a4ce0 | ||
|
|
e94e2c0172 | ||
|
|
8a8af0d4af | ||
|
|
cfb3922705 | ||
|
|
3b6cba4f08 | ||
|
|
830674adc9 | ||
|
|
e164e26164 | ||
|
|
4e40b037cd | ||
|
|
fe84736f31 | ||
|
|
5950e00c10 | ||
|
|
fb6ff274ae | ||
|
|
e52e90fd79 | ||
|
|
86ae763d0e | ||
|
|
83edbca038 | ||
|
|
542dcc5dd4 | ||
|
|
b3fb369d89 | ||
|
|
53d3955d06 | ||
|
|
a3724498b5 | ||
|
|
0ca97ff587 | ||
|
|
744e0b3d66 | ||
|
|
f4efa23a5d | ||
|
|
dd88233151 | ||
|
|
fbfe3abffd | ||
|
|
cae1885c3a | ||
|
|
4f752a0677 | ||
|
|
6ea5783a3e | ||
|
|
7c643b6a80 | ||
|
|
ebb6f8f43c | ||
|
|
93151f62a6 | ||
|
|
798689d76d | ||
|
|
5808d8dcf3 | ||
|
|
5155891bfc | ||
|
|
17cbbefb68 | ||
|
|
4ee1e13365 | ||
|
|
53ad490ff8 | ||
|
|
527e9d2333 | ||
|
|
86c4a5e80c | ||
|
|
5fafb3bbcc | ||
|
|
4d1c0f7d3a | ||
|
|
7739f4315e | ||
|
|
50f4692c76 | ||
|
|
203ff03fdf | ||
|
|
af4ed12684 | ||
|
|
7d4304907c | ||
|
|
0bd0da7d7c | ||
|
|
6b5787753a | ||
|
|
8914ba161c | ||
|
|
8a39e196dc | ||
|
|
a2dda9c8b5 | ||
|
|
17d4201376 | ||
|
|
9266ba3539 | ||
|
|
1de08f3eeb | ||
|
|
ab5d58a6ed | ||
|
|
bb3471a1c2 | ||
|
|
87dfc5a6d7 |
|
|
@ -4,6 +4,9 @@
|
|||
**/traces
|
||||
**/inputs
|
||||
**/har
|
||||
**/downloads
|
||||
**/browser_sessions
|
||||
**/credential_vault
|
||||
**/.git
|
||||
**/.github
|
||||
**/.mypy_cache
|
||||
|
|
|
|||
17
.env.example
|
|
@ -139,12 +139,29 @@ ANALYTICS_ID="anonymous"
|
|||
# OP_SERVICE_ACCOUNT_TOKEN: API token for 1Password integration
|
||||
OP_SERVICE_ACCOUNT_TOKEN=""
|
||||
|
||||
# =============================================================================
|
||||
# SKYVERN CREDENTIAL VAULT CONFIGURATION
|
||||
# =============================================================================
|
||||
# Built-in encrypted local vault used by default by the OSS Docker Compose setup.
|
||||
# Docker also sets ENABLE_LOCAL_CREDENTIAL_VAULT=true and persists this under /data/credential_vault.
|
||||
CREDENTIAL_VAULT_TYPE=skyvern
|
||||
ENABLE_LOCAL_CREDENTIAL_VAULT=true
|
||||
# LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault
|
||||
# Production self-hosters should set LOCAL_CREDENTIAL_VAULT_KEY from an external secret store.
|
||||
# If unset, Skyvern generates .fernet_key inside LOCAL_CREDENTIAL_VAULT_PATH; backups or host-dir
|
||||
# exposure of that directory include both encrypted items and the key needed to decrypt them.
|
||||
# LOCAL_CREDENTIAL_VAULT_KEY=
|
||||
# The OSS Docker volumes for browser_sessions/ and downloads/ can contain plaintext cookies,
|
||||
# session tokens, and downloaded files. Treat them as sensitive and exclude them from casual backups.
|
||||
|
||||
# Enable recording skyvern logs as artifacts
|
||||
ENABLE_LOG_ARTIFACTS=false
|
||||
|
||||
# =============================================================================
|
||||
# SKYVERN BITWARDEN CONFIGURATION
|
||||
# =============================================================================
|
||||
# Set CREDENTIAL_VAULT_TYPE=bitwarden to use Bitwarden or vaultwarden instead
|
||||
# of the built-in Skyvern credential vault.
|
||||
# Your organization ID in official Bitwarden server or vaultwarden (if using organizations)
|
||||
SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID=your-org-id-here
|
||||
|
||||
|
|
|
|||
2
.gitignore
vendored
|
|
@ -162,6 +162,7 @@ ig_*
|
|||
|
||||
# Skyvern ignores
|
||||
browser_sessions/
|
||||
credential_vault/
|
||||
videos/
|
||||
skyvern/artifacts/
|
||||
artifacts/
|
||||
|
|
@ -189,6 +190,7 @@ tags.temp.tmp
|
|||
|
||||
# copy of Chrome user profile from Chrome >= 136
|
||||
tmp/user_data_dir
|
||||
tmp/
|
||||
|
||||
# Claude
|
||||
.claude/*
|
||||
|
|
|
|||
12
Dockerfile
|
|
@ -11,6 +11,7 @@ RUN uv pip compile pyproject.toml --extra server --python-version 3.11 -o requir
|
|||
FROM python:3.11-slim-bookworm
|
||||
WORKDIR /app
|
||||
COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt
|
||||
COPY ./skyvern/forge/sdk/utils/tesseract_languages.py /tmp/tesseract_languages.py
|
||||
RUN pip install --upgrade pip setuptools wheel
|
||||
# --no-deps: requirements.txt is fully resolved by uv, including the
|
||||
# pyproject overrides that loosen litellm's jsonschema==4.23.0 pin.
|
||||
|
|
@ -18,7 +19,13 @@ RUN pip install --upgrade pip setuptools wheel
|
|||
RUN pip install --no-cache-dir --no-deps -r requirements.txt
|
||||
RUN playwright install-deps
|
||||
RUN playwright install
|
||||
RUN apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc && apt-get clean
|
||||
RUN apt-get update && \
|
||||
apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc tesseract-ocr $(python /tmp/tesseract_languages.py --apt-packages) && \
|
||||
tesseract --version && \
|
||||
rm /tmp/tesseract_languages.py && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir websockify
|
||||
|
||||
COPY .nvmrc /app/.nvmrc
|
||||
|
|
@ -47,6 +54,9 @@ ENV VIDEO_PATH=/data/videos
|
|||
ENV HAR_PATH=/data/har
|
||||
ENV LOG_PATH=/data/log
|
||||
ENV ARTIFACT_STORAGE_PATH=/data/artifacts
|
||||
ENV DOWNLOAD_PATH=/data/downloads
|
||||
ENV BROWSER_SESSION_BASE_PATH=/data/browser_sessions
|
||||
ENV LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault
|
||||
|
||||
# cache tiktoken
|
||||
RUN python /app/scripts/load_tiktoken.py
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"""managed browser profiles for save and reuse session
|
||||
|
||||
Revision ID: ecf365563e98
|
||||
Revises: a1cad008154a
|
||||
Create Date: 2026-07-02T23:17:33.611632+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ecf365563e98"
|
||||
down_revision: Union[str, None] = "a1cad008154a"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"browser_profiles",
|
||||
sa.Column("is_managed", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
)
|
||||
op.add_column("browser_profiles", sa.Column("workflow_permanent_id", sa.String(), nullable=True))
|
||||
op.add_column("browser_profiles", sa.Column("browser_profile_key_digest", sa.String(), nullable=True))
|
||||
op.create_index(
|
||||
"uq_browser_profiles_org_name_user",
|
||||
"browser_profiles",
|
||||
["organization_id", "name"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_managed = false"),
|
||||
sqlite_where=sa.text("is_managed = false"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_browser_profiles_managed_segment",
|
||||
"browser_profiles",
|
||||
["organization_id", "workflow_permanent_id", "browser_profile_key_digest"],
|
||||
unique=True,
|
||||
# Exclude soft-deleted rows so deleting a managed profile doesn't tombstone the
|
||||
# segment — the next run re-creates it instead of colliding on the unique key.
|
||||
postgresql_where=sa.text("is_managed = true AND deleted_at IS NULL"),
|
||||
sqlite_where=sa.text("is_managed = true AND deleted_at IS NULL"),
|
||||
)
|
||||
op.create_index("idx_browser_profiles_wpid", "browser_profiles", ["workflow_permanent_id"], unique=False)
|
||||
op.drop_constraint("uc_org_browser_profile_name", "browser_profiles", type_="unique")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM browser_profiles WHERE is_managed = true")
|
||||
op.create_unique_constraint("uc_org_browser_profile_name", "browser_profiles", ["organization_id", "name"])
|
||||
op.drop_index("idx_browser_profiles_wpid", table_name="browser_profiles")
|
||||
op.drop_index(
|
||||
"uq_browser_profiles_managed_segment",
|
||||
table_name="browser_profiles",
|
||||
postgresql_where=sa.text("is_managed = true AND deleted_at IS NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_browser_profiles_org_name_user",
|
||||
table_name="browser_profiles",
|
||||
postgresql_where=sa.text("is_managed = false"),
|
||||
)
|
||||
op.drop_column("browser_profiles", "browser_profile_key_digest")
|
||||
op.drop_column("browser_profiles", "workflow_permanent_id")
|
||||
op.drop_column("browser_profiles", "is_managed")
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""add enable_self_healing to workflows
|
||||
|
||||
Revision ID: 2ac47bc1c075
|
||||
Revises: ecf365563e98
|
||||
Create Date: 2026-07-03T03:33:20.863940+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2ac47bc1c075"
|
||||
down_revision: Union[str, None] = "ecf365563e98"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"workflows",
|
||||
sa.Column("enable_self_healing", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("workflows", "enable_self_healing")
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""add workflow run credential selections
|
||||
|
||||
Revision ID: aff1632dc377
|
||||
Revises: 2ac47bc1c075
|
||||
Create Date: 2026-07-03T18:53:23.324057+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "aff1632dc377"
|
||||
down_revision: Union[str, None] = "2ac47bc1c075"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("credential_parameters", sa.Column("credential_ids", sa.JSON(), nullable=True))
|
||||
op.add_column("credential_parameters", sa.Column("selection_strategy", sa.String(), nullable=True))
|
||||
op.create_table(
|
||||
"workflow_run_credential_selections",
|
||||
sa.Column("selection_id", sa.String(), nullable=False),
|
||||
sa.Column("organization_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_run_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_permanent_id", sa.String(), nullable=False),
|
||||
sa.Column("parameter_key", sa.String(), nullable=False),
|
||||
sa.Column("credential_id", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("selection_id"),
|
||||
sa.UniqueConstraint("workflow_run_id", "parameter_key", name="uq_wrcs_workflow_run_parameter_key"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_wrcs_lru_lookup",
|
||||
"workflow_run_credential_selections",
|
||||
["organization_id", "workflow_permanent_id", "parameter_key", "credential_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_wrcs_lru_lookup", table_name="workflow_run_credential_selections")
|
||||
op.drop_table("workflow_run_credential_selections")
|
||||
op.drop_column("credential_parameters", "selection_strategy")
|
||||
op.drop_column("credential_parameters", "credential_ids")
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""browser profile name unique partial on deleted_at
|
||||
|
||||
Revision ID: e2d87251fee8
|
||||
Revises: aff1632dc377
|
||||
Create Date: 2026-07-07T15:35:51.042177+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e2d87251fee8"
|
||||
down_revision: Union[str, None] = "aff1632dc377"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index(
|
||||
"uq_browser_profiles_org_name_user",
|
||||
table_name="browser_profiles",
|
||||
postgresql_where=sa.text("is_managed = false"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_browser_profiles_org_name_user",
|
||||
"browser_profiles",
|
||||
["organization_id", "name"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_managed = false AND deleted_at IS NULL"),
|
||||
sqlite_where=sa.text("is_managed = false AND deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"uq_browser_profiles_org_name_user",
|
||||
table_name="browser_profiles",
|
||||
postgresql_where=sa.text("is_managed = false AND deleted_at IS NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_browser_profiles_org_name_user",
|
||||
"browser_profiles",
|
||||
["organization_id", "name"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_managed = false"),
|
||||
sqlite_where=sa.text("is_managed = false"),
|
||||
)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""add pin_saved_session_ip to workflows
|
||||
|
||||
Revision ID: 5b618ec6dce6
|
||||
Revises: e2d87251fee8
|
||||
Create Date: 2026-07-07T21:21:37.964227+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5b618ec6dce6"
|
||||
down_revision: Union[str, None] = "e2d87251fee8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("SET LOCAL lock_timeout = '5s'")
|
||||
op.add_column(
|
||||
"workflows",
|
||||
sa.Column("pin_saved_session_ip", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("workflows", "pin_saved_session_ip")
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
"""add workflow run tag events table
|
||||
|
||||
Revision ID: bec06d149264
|
||||
Revises: 5b618ec6dce6
|
||||
Create Date: 2026-07-08T17:54:31.712270+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "bec06d149264"
|
||||
down_revision: Union[str, None] = "5b618ec6dce6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workflow_run_tag_events",
|
||||
sa.Column("tag_event_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_run_id", sa.String(), nullable=False),
|
||||
sa.Column("organization_id", sa.String(), nullable=False),
|
||||
sa.Column("key", sa.String(), nullable=True),
|
||||
sa.Column("value", sa.String(), nullable=True),
|
||||
sa.Column("event_type", sa.String(), nullable=False),
|
||||
sa.Column("set_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("set_by", sa.String(), nullable=False),
|
||||
sa.Column("source", sa.String(), nullable=False),
|
||||
sa.Column("caller_type", sa.String(), nullable=True),
|
||||
sa.Column("superseded_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("inherited_from_tag_event_id", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("modified_at", sa.DateTime(), nullable=False),
|
||||
sa.CheckConstraint("event_type IN ('set', 'delete')", name="ck_workflow_run_tag_events_event_type"),
|
||||
sa.CheckConstraint(
|
||||
"source IN ('manual', 'bulk_apply', 'backfill', 'inherited', 'import', 'system')",
|
||||
name="ck_workflow_run_tag_events_source",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"caller_type IS NULL OR caller_type IN ('user', 'api_key', 'system')",
|
||||
name="ck_workflow_run_tag_events_caller_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"event_type != 'set' OR value IS NOT NULL",
|
||||
name="ck_workflow_run_tag_events_set_has_value",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["workflow_run_id"],
|
||||
["workflow_runs.workflow_run_id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"],
|
||||
["organizations.organization_id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("tag_event_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"workflow_run_tag_events_org_wr_set_at_idx",
|
||||
"workflow_run_tag_events",
|
||||
["organization_id", "workflow_run_id", "set_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"workflow_run_tag_events_org_key_value_active_idx",
|
||||
"workflow_run_tag_events",
|
||||
["organization_id", "key", "value"],
|
||||
unique=False,
|
||||
postgresql_include=["workflow_run_id"],
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
|
||||
)
|
||||
op.create_index(
|
||||
"workflow_run_tag_events_org_value_active_idx",
|
||||
"workflow_run_tag_events",
|
||||
["organization_id", "value"],
|
||||
unique=False,
|
||||
postgresql_include=["workflow_run_id"],
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
|
||||
)
|
||||
op.create_index(
|
||||
"workflow_run_tag_events_org_set_at_idx",
|
||||
"workflow_run_tag_events",
|
||||
["organization_id", "set_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"workflow_run_tag_events_active_grouped_unique",
|
||||
"workflow_run_tag_events",
|
||||
["organization_id", "workflow_run_id", "key"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
|
||||
sqlite_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"workflow_run_tag_events_active_label_unique",
|
||||
"workflow_run_tag_events",
|
||||
["organization_id", "workflow_run_id", "value"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
|
||||
sqlite_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"workflow_run_tag_events_active_label_unique",
|
||||
table_name="workflow_run_tag_events",
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"workflow_run_tag_events_active_grouped_unique",
|
||||
table_name="workflow_run_tag_events",
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
|
||||
)
|
||||
op.drop_index("workflow_run_tag_events_org_set_at_idx", table_name="workflow_run_tag_events")
|
||||
op.drop_index(
|
||||
"workflow_run_tag_events_org_value_active_idx",
|
||||
table_name="workflow_run_tag_events",
|
||||
postgresql_include=["workflow_run_id"],
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
|
||||
)
|
||||
op.drop_index(
|
||||
"workflow_run_tag_events_org_key_value_active_idx",
|
||||
table_name="workflow_run_tag_events",
|
||||
postgresql_include=["workflow_run_id"],
|
||||
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
|
||||
)
|
||||
op.drop_index("workflow_run_tag_events_org_wr_set_at_idx", table_name="workflow_run_tag_events")
|
||||
op.drop_table("workflow_run_tag_events")
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"""add heal episodes and proposals
|
||||
|
||||
Revision ID: e1227914ecfe
|
||||
Revises: bec06d149264
|
||||
Create Date: 2026-07-09T22:47:11.311011+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e1227914ecfe"
|
||||
down_revision: Union[str, None] = "bec06d149264"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"heal_episodes",
|
||||
sa.Column("heal_episode_id", sa.String(), nullable=False),
|
||||
sa.Column("organization_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_permanent_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_run_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_run_block_id", sa.String(), nullable=False),
|
||||
sa.Column("block_label", sa.String(), nullable=False),
|
||||
sa.Column("engine", sa.String(), nullable=False),
|
||||
sa.Column("status", sa.String(), nullable=False),
|
||||
sa.Column("skip_reason", sa.String(), nullable=True),
|
||||
sa.Column("block_prompt", sa.UnicodeText(), nullable=True),
|
||||
sa.Column("block_code", sa.UnicodeText(), nullable=True),
|
||||
sa.Column("block_steps", sa.JSON(), nullable=True),
|
||||
sa.Column("snapshot_available", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("convergence_eligible", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("parameter_binding_keys", sa.JSON(), nullable=True),
|
||||
sa.Column("exception_class", sa.String(), nullable=True),
|
||||
sa.Column("failing_line", sa.Integer(), nullable=True),
|
||||
sa.Column("matched_step_index", sa.Integer(), nullable=True),
|
||||
sa.Column("failure_message", sa.UnicodeText(), nullable=True),
|
||||
sa.Column("escalation_task_id", sa.String(), nullable=True),
|
||||
sa.Column("wall_clock_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("action_count", sa.Integer(), nullable=True),
|
||||
sa.Column("output_obligation", sa.String(), nullable=True),
|
||||
sa.Column("dom_snapshot_artifact_id", sa.String(), nullable=True),
|
||||
sa.Column("scout_transcript_artifact_id", sa.String(), nullable=True),
|
||||
sa.Column("screenshot_artifact_id", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("modified_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("heal_episode_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"he_org_wpid_index",
|
||||
"heal_episodes",
|
||||
["organization_id", "workflow_permanent_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("he_org_created_at_index", "heal_episodes", ["organization_id", "created_at"], unique=False)
|
||||
op.create_index("he_org_wrid_index", "heal_episodes", ["organization_id", "workflow_run_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"workflow_heal_proposals",
|
||||
sa.Column("heal_proposal_id", sa.String(), nullable=False),
|
||||
sa.Column("organization_id", sa.String(), nullable=False),
|
||||
sa.Column("workflow_permanent_id", sa.String(), nullable=False),
|
||||
sa.Column("block_label", sa.String(), nullable=False),
|
||||
sa.Column("candidate_definition", sa.JSON(), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=True),
|
||||
sa.Column("episode_ids", sa.JSON(), nullable=False),
|
||||
sa.Column("rendered_diff", sa.UnicodeText(), nullable=True),
|
||||
sa.Column("base_version", sa.Integer(), nullable=False),
|
||||
sa.Column("base_definition_hash", sa.String(), nullable=False),
|
||||
sa.Column("status", sa.String(), nullable=False, server_default=sa.text("'proposed'")),
|
||||
sa.Column("adopted_workflow_id", sa.String(), nullable=True),
|
||||
sa.Column("episode_window", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("modified_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("heal_proposal_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"hp_org_wpid_index",
|
||||
"workflow_heal_proposals",
|
||||
["organization_id", "workflow_permanent_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column("selfheal_screenshot_capture_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column("organizations", sa.Column("selfheal_artifact_retention_days", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("organizations", "selfheal_artifact_retention_days")
|
||||
op.drop_column("organizations", "selfheal_screenshot_capture_enabled")
|
||||
|
||||
op.drop_index("hp_org_wpid_index", table_name="workflow_heal_proposals")
|
||||
op.drop_table("workflow_heal_proposals")
|
||||
|
||||
op.drop_index("he_org_wrid_index", table_name="heal_episodes")
|
||||
op.drop_index("he_org_created_at_index", table_name="heal_episodes")
|
||||
op.drop_index("he_org_wpid_index", table_name="heal_episodes")
|
||||
op.drop_table("heal_episodes")
|
||||
|
|
@ -38,6 +38,9 @@ services:
|
|||
- ./videos:/data/videos
|
||||
- ./har:/data/har
|
||||
- ./log:/data/log
|
||||
- ./downloads:/data/downloads
|
||||
- ./browser_sessions:/data/browser_sessions
|
||||
- ./credential_vault:/data/credential_vault
|
||||
# Generated credentials allow the UI to pick up the local API key on first startup.
|
||||
- ./.skyvern:/app/.skyvern
|
||||
# Uncomment the following two lines if you want to connect to any local changes
|
||||
|
|
@ -62,6 +65,11 @@ services:
|
|||
- BROWSER_REMOTE_DEBUGGING_URL=${BROWSER_REMOTE_DEBUGGING_URL:-http://127.0.0.1:9222}
|
||||
- BROWSER_REMOTE_DEBUGGING_HOST_HEADER=${BROWSER_REMOTE_DEBUGGING_HOST_HEADER:-}
|
||||
- BROWSER_CDP_CONNECT_TIMEOUT_MS=${BROWSER_CDP_CONNECT_TIMEOUT_MS:-120000}
|
||||
- DOWNLOAD_PATH=/data/downloads
|
||||
- BROWSER_SESSION_BASE_PATH=/data/browser_sessions
|
||||
- CREDENTIAL_VAULT_TYPE=${CREDENTIAL_VAULT_TYPE:-skyvern}
|
||||
- ENABLE_LOCAL_CREDENTIAL_VAULT=${ENABLE_LOCAL_CREDENTIAL_VAULT:-true}
|
||||
- LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault
|
||||
- ENABLE_CODE_BLOCK=true
|
||||
# --- Control your own browser (Chrome/Chromium) ---
|
||||
# Prefer Chrome's chrome://inspect/#remote-debugging flow for an existing profile.
|
||||
|
|
|
|||
|
|
@ -2683,6 +2683,24 @@
|
|||
},
|
||||
"description": "Case-insensitive substring search across: browser profile name and description. A profile is returned if either field matches."
|
||||
},
|
||||
{
|
||||
"name": "managed",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Omit to return all profiles; false returns only user-created profiles; true returns only auto-managed profiles.",
|
||||
"title": "Managed"
|
||||
},
|
||||
"description": "Omit to return all profiles; false returns only user-created profiles; true returns only auto-managed profiles."
|
||||
},
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
|
|
@ -3645,10 +3663,10 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')",
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')",
|
||||
"title": "Vault Type"
|
||||
},
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')"
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')"
|
||||
},
|
||||
{
|
||||
"name": "credential_type",
|
||||
|
|
@ -4018,6 +4036,256 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/v1/custom-llms": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Custom LLMs"
|
||||
],
|
||||
"summary": "List Custom LLMs",
|
||||
"description": "List the custom LLM configurations available to the current organization.",
|
||||
"operationId": "list_custom_llms_v1_custom_llms_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.",
|
||||
"title": "X-Api-Key"
|
||||
},
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CustomLLMListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-fern-sdk-method-name": "list_custom_llms"
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Custom LLMs"
|
||||
],
|
||||
"summary": "Create Custom LLM",
|
||||
"description": "Create a custom LLM configuration for the current organization.",
|
||||
"operationId": "create_custom_llm_v1_custom_llms_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.",
|
||||
"title": "X-Api-Key"
|
||||
},
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings."
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CustomLLMCreateRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CustomLLMResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-fern-sdk-method-name": "create_custom_llm"
|
||||
}
|
||||
},
|
||||
"/v1/custom-llms/{custom_llm_id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Custom LLMs"
|
||||
],
|
||||
"summary": "Update Custom LLM",
|
||||
"description": "Update a custom LLM configuration for the current organization.",
|
||||
"operationId": "update_custom_llm_v1_custom_llms__custom_llm_id__put",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "custom_llm_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The custom LLM id.",
|
||||
"title": "Custom Llm Id"
|
||||
},
|
||||
"description": "The custom LLM id."
|
||||
},
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.",
|
||||
"title": "X-Api-Key"
|
||||
},
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings."
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CustomLLMUpdateRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CustomLLMResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-fern-sdk-method-name": "update_custom_llm"
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Custom LLMs"
|
||||
],
|
||||
"summary": "Delete Custom LLM",
|
||||
"description": "Delete a custom LLM configuration for the current organization.",
|
||||
"operationId": "delete_custom_llm_v1_custom_llms__custom_llm_id__delete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "custom_llm_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The custom LLM id.",
|
||||
"title": "Custom Llm Id"
|
||||
},
|
||||
"description": "The custom LLM id."
|
||||
},
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings.",
|
||||
"title": "X-Api-Key"
|
||||
},
|
||||
"description": "Skyvern API key for authentication. API key can be found at https://app.skyvern.com/settings."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClearOrganizationAuthTokenResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-fern-sdk-method-name": "delete_custom_llm"
|
||||
}
|
||||
},
|
||||
"/v1/run/tasks/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -5559,6 +5827,44 @@
|
|||
},
|
||||
"description": "Exact-match filter on the error_code field inside each task's errors JSON array. A run matches if any of its tasks contains an error with a matching error_code. Error codes are user-defined strings set during workflow execution."
|
||||
},
|
||||
{
|
||||
"name": "created_at_start",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Only include runs created at or after this UTC timestamp (ISO 8601).",
|
||||
"title": "Created At Start"
|
||||
},
|
||||
"description": "Only include runs created at or after this UTC timestamp (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"name": "created_at_end",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Only include runs created strictly before this UTC timestamp (ISO 8601).",
|
||||
"title": "Created At End"
|
||||
},
|
||||
"description": "Only include runs created strictly before this UTC timestamp (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
|
|
@ -8669,6 +8975,33 @@
|
|||
],
|
||||
"title": "Proxy Session Id"
|
||||
},
|
||||
"is_managed": {
|
||||
"type": "boolean",
|
||||
"title": "Is Managed",
|
||||
"default": false
|
||||
},
|
||||
"workflow_permanent_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Workflow Permanent Id"
|
||||
},
|
||||
"browser_profile_key_digest": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Browser Profile Key Digest"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
|
|
@ -9013,6 +9346,21 @@
|
|||
],
|
||||
"title": "BulkCancelRunsResponse"
|
||||
},
|
||||
"ClearOrganizationAuthTokenResponse": {
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"title": "Success",
|
||||
"description": "Whether the token was cleared successfully"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"success"
|
||||
],
|
||||
"title": "ClearOrganizationAuthTokenResponse",
|
||||
"description": "Response model for clearing an organization auth token."
|
||||
},
|
||||
"ClickAction": {
|
||||
"properties": {
|
||||
"type": {
|
||||
|
|
@ -9964,6 +10312,7 @@
|
|||
],
|
||||
"description": "Which vault to store this credential in. If omitted, uses the instance default. Use this to mix Skyvern-hosted and custom credentials within the same organization.",
|
||||
"examples": [
|
||||
"skyvern",
|
||||
"custom",
|
||||
"azure_vault",
|
||||
"bitwarden"
|
||||
|
|
@ -10170,6 +10519,31 @@
|
|||
"type": "string",
|
||||
"title": "Credential Id"
|
||||
},
|
||||
"credential_ids": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Credential Ids"
|
||||
},
|
||||
"selection_strategy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Selection Strategy"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
|
|
@ -10230,6 +10604,31 @@
|
|||
"credential_id": {
|
||||
"type": "string",
|
||||
"title": "Credential Id"
|
||||
},
|
||||
"credential_ids": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Credential Ids"
|
||||
},
|
||||
"selection_strategy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Selection Strategy"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -10285,7 +10684,7 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')"
|
||||
"description": "Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')"
|
||||
},
|
||||
"browser_profile_id": {
|
||||
"anyOf": [
|
||||
|
|
@ -10405,6 +10804,7 @@
|
|||
"CredentialVaultType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"skyvern",
|
||||
"bitwarden",
|
||||
"azure_vault",
|
||||
"gcp",
|
||||
|
|
@ -10566,6 +10966,214 @@
|
|||
"title": "CreditCardCredentialResponse",
|
||||
"description": "Response model for credit card credentials — non-sensitive fields only.\n\nSECURITY: Must NEVER include full card number, CVV, expiration date, card holder name,\nbilling fields, or metadata."
|
||||
},
|
||||
"CustomLLM": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string",
|
||||
"title": "Organization Id"
|
||||
},
|
||||
"config": {
|
||||
"$ref": "#/components/schemas/CustomLLMConfig"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"title": "Created At"
|
||||
},
|
||||
"modified_at": {
|
||||
"type": "string",
|
||||
"title": "Modified At"
|
||||
},
|
||||
"valid": {
|
||||
"type": "boolean",
|
||||
"title": "Valid"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"organization_id",
|
||||
"config",
|
||||
"created_at",
|
||||
"modified_at",
|
||||
"valid"
|
||||
],
|
||||
"title": "CustomLLM"
|
||||
},
|
||||
"CustomLLMConfig": {
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string",
|
||||
"maxLength": 120,
|
||||
"minLength": 1,
|
||||
"title": "Display Name"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/components/schemas/CustomLLMProvider"
|
||||
},
|
||||
"model_name": {
|
||||
"type": "string",
|
||||
"maxLength": 250,
|
||||
"minLength": 1,
|
||||
"title": "Model Name"
|
||||
},
|
||||
"api_base": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 500
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Api Base"
|
||||
},
|
||||
"api_key": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Api Key"
|
||||
},
|
||||
"api_version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 100
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Api Version"
|
||||
},
|
||||
"supports_vision": {
|
||||
"type": "boolean",
|
||||
"title": "Supports Vision",
|
||||
"default": true
|
||||
},
|
||||
"add_assistant_prefix": {
|
||||
"type": "boolean",
|
||||
"title": "Add Assistant Prefix",
|
||||
"default": false
|
||||
},
|
||||
"max_completion_tokens": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 1000000.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Max Completion Tokens"
|
||||
},
|
||||
"temperature": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number",
|
||||
"maximum": 2.0,
|
||||
"minimum": 0.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Temperature"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 50
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reasoning Effort"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"display_name",
|
||||
"provider",
|
||||
"model_name"
|
||||
],
|
||||
"title": "CustomLLMConfig"
|
||||
},
|
||||
"CustomLLMCreateRequest": {
|
||||
"properties": {
|
||||
"config": {
|
||||
"$ref": "#/components/schemas/CustomLLMConfig"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"config"
|
||||
],
|
||||
"title": "CustomLLMCreateRequest"
|
||||
},
|
||||
"CustomLLMListResponse": {
|
||||
"properties": {
|
||||
"custom_llms": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CustomLLM"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Custom Llms"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"custom_llms"
|
||||
],
|
||||
"title": "CustomLLMListResponse"
|
||||
},
|
||||
"CustomLLMProvider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"openai_compatible",
|
||||
"ollama",
|
||||
"openrouter"
|
||||
],
|
||||
"title": "CustomLLMProvider"
|
||||
},
|
||||
"CustomLLMResponse": {
|
||||
"properties": {
|
||||
"custom_llm": {
|
||||
"$ref": "#/components/schemas/CustomLLM"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"custom_llm"
|
||||
],
|
||||
"title": "CustomLLMResponse"
|
||||
},
|
||||
"CustomLLMUpdateRequest": {
|
||||
"properties": {
|
||||
"config": {
|
||||
"$ref": "#/components/schemas/CustomLLMConfig"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"config"
|
||||
],
|
||||
"title": "CustomLLMUpdateRequest"
|
||||
},
|
||||
"DeleteScheduleResponse": {
|
||||
"properties": {
|
||||
"ok": {
|
||||
|
|
@ -12295,7 +12903,8 @@
|
|||
"excel",
|
||||
"pdf",
|
||||
"image",
|
||||
"docx"
|
||||
"docx",
|
||||
"zip"
|
||||
],
|
||||
"title": "FileType"
|
||||
},
|
||||
|
|
@ -14106,6 +14715,20 @@
|
|||
"title": "Save Response As File",
|
||||
"default": false
|
||||
},
|
||||
"secret_response_paths": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Secret Response Paths"
|
||||
},
|
||||
"parameters": {
|
||||
"items": {
|
||||
"oneOf": [
|
||||
|
|
@ -14306,6 +14929,20 @@
|
|||
"title": "Save Response As File",
|
||||
"default": false
|
||||
},
|
||||
"secret_response_paths": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Secret Response Paths"
|
||||
},
|
||||
"parameter_keys": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -23471,6 +24108,11 @@
|
|||
"title": "Persist Browser Session",
|
||||
"default": false
|
||||
},
|
||||
"pin_saved_session_ip": {
|
||||
"type": "boolean",
|
||||
"title": "Pin Saved Session Ip",
|
||||
"default": false
|
||||
},
|
||||
"browser_profile_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -23585,6 +24227,11 @@
|
|||
"title": "Adaptive Caching",
|
||||
"default": false
|
||||
},
|
||||
"enable_self_healing": {
|
||||
"type": "boolean",
|
||||
"title": "Enable Self Healing",
|
||||
"default": false
|
||||
},
|
||||
"code_version": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -23667,6 +24314,11 @@
|
|||
],
|
||||
"title": "Edited By"
|
||||
},
|
||||
"copilot_authored": {
|
||||
"type": "boolean",
|
||||
"title": "Copilot Authored",
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
|
|
@ -23784,6 +24436,11 @@
|
|||
"title": "Persist Browser Session",
|
||||
"default": false
|
||||
},
|
||||
"pin_saved_session_ip": {
|
||||
"type": "boolean",
|
||||
"title": "Pin Saved Session Ip",
|
||||
"default": false
|
||||
},
|
||||
"browser_profile_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -23909,6 +24566,17 @@
|
|||
"title": "Adaptive Caching",
|
||||
"default": false
|
||||
},
|
||||
"enable_self_healing": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Enable Self Healing"
|
||||
},
|
||||
"code_version": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -25396,6 +26064,17 @@
|
|||
],
|
||||
"title": "Body"
|
||||
},
|
||||
"prompt": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Prompt"
|
||||
},
|
||||
"instructions": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
114
docs/changelog-emails/2026-06-june.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||

|
||||
|
||||
# Skyvern Changelog — June 2026
|
||||
|
||||
*Everything we shipped in June 2026 — weeks of June 1, 8, 15, and 22.*
|
||||
|
||||
---
|
||||
|
||||
## Workflows Are Now Agents
|
||||
|
||||
We renamed **Workflows to Agents** across the product, the public API, and the docs. The API now lives under **`/agents`** (e.g. `POST /v1/run/agents`) and accepts an **`agent_id`** alias for `workflow_id` — same `wpid_` value, echoed back in responses. It's fully backwards-compatible: your existing `workflow_id` inputs, `/workflows` endpoints, SDK methods, and bookmarked doc URLs all keep working.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Workflow Studio (Preview)
|
||||
|
||||
Meet the next-generation editor. **Workflow Studio** is an opt-in preview that reimagines how you build: a refreshed **build canvas**, an **integrated live browser view**, and a built-in **run viewer** — all in one place. Take control of the live browser, center the viewport, and watch a run unfold without leaving the editor. Flip it on from Feature Preview in Settings.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Code-First Code Blocks
|
||||
|
||||
For when you want to drop to code. Code blocks now have a **dedicated code editor** with **syntax highlighting**, **Jinja parameter hints**, and a **run timeline** that records every action — including `page.evaluate(...)` calls. A **Build ↔ Code toggle** flips between visual and code editing, block labels and extraction output render correctly, and Copilot-generated code blocks support a **typed extraction schema** and fill logins with your stored credentials.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Copilot: Ask, Build, Code — With Memory
|
||||
|
||||
The Copilot got sharper about intent and context. It now **routes every request by mode** — **Ask** answers without touching your workflow, **Build** creates or edits it, **Code** generates executable code blocks. It **remembers the full chat history per workflow**, so prior context and decisions carry forward as you keep building. And its **repair loop is honest now**: when consecutive fixes make no verified progress, it stops after three tries and escalates cleanly — draft preserved — instead of spinning.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## A Bigger Model Menu
|
||||
|
||||
More choice for every run. New this month: **DeepSeek**, **Xiaomi MiMo** (and **MiMo-V2.5** via OpenRouter), **Gemini 3.5 Flash**, **GPT-5-mini** (your own OpenAI key or via OpenRouter), and **Claude Opus 4.8** (Anthropic + Bedrock). OpenRouter runs now track token counts and cost like every other provider. (Gemini 2.5 Pro/Flash are deprecated; CUA and the Claude Opus family are now enterprise-only.)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Credentials & OTP, Everywhere
|
||||
|
||||
Logins that just work. Pull credentials straight from **1Password** (with an item picker) or **Bitwarden**, look up one-time passcodes from **Gmail** via OAuth, and **fill OTP at runtime** from your stored TOTP secrets — even inside code blocks. Expired credential sessions **auto re-save**, and you can **clear a saved credential** without deleting and recreating it.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Multi-Tab Browser Control
|
||||
|
||||
Automations that span tabs. The autonomous agent loop can now **open and work across multiple browser tabs** at once — following new-tab links and handling pop-ups — so flows that jump between tabs no longer dead-end.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Self-Host on Google Cloud
|
||||
|
||||
Full GCP-native deployments. Self-hosted Skyvern now supports **Google Cloud Storage** for artifacts and **GCP Secret Manager** as a vault backend — so you can run the whole stack on Google Cloud with your own storage and secrets.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Quick List — June
|
||||
|
||||
**New features**
|
||||
- **Analytics drill-down & tag filters** — period-aware per-agent drill-down (avg duration, credits/run, period-over-period trends) plus filtering runs and metrics by workflow tag
|
||||
- **Agent Directory Tree** — the agents/folders list is now a navigable tree for nested hierarchies
|
||||
- **Self-serve editor onboarding** — a get-started modal, a 4-stop guided tour, and smart empty states for faster time-to-first-automation
|
||||
- **Bulk actions on the agents list** — select multiple agents and act on them at once
|
||||
- **Label management in Settings** — rename, recolor, and soft-delete tags
|
||||
- **Browser profiles in MCP sessions** — associate a browser profile with MCP-driven runs for credential/state reuse
|
||||
- **Feature Preview panel in Settings** — opt into upcoming features before they're fully released
|
||||
- **Workflow schedules in open source** — self-hosted deployments now support schedules
|
||||
- **Uncapped workflow runtime** — configure long-running automations with no maximum time limit
|
||||
|
||||
**Improvements**
|
||||
- Structured output is verified against its schema before a run is marked complete
|
||||
- Tasks return a best-effort **partial deliverable** when they exhaust their credit budget
|
||||
- The task planner prefers **loop-based tasks** for repeated same-shaped work, and can now emit EXTRACT_INFORMATION / GOTO_URL actions
|
||||
- The agent **acts immediately on stalled pages** and **halts reliably on terminal blockers** instead of spinning
|
||||
- Remote browser sessions capped at 4 hours; debug sessions stay open after a run so you can inspect the final page
|
||||
- End-state screenshots captured for **every open tab** at completion; per-run recordings clipped to the run window and stored as downloadable artifacts
|
||||
- Run app + recording URLs surfaced in MCP and CLI responses; run-ID search fallback on the runs list
|
||||
- Batched cache invalidation on workflow save (removed a per-block N+1) for faster saves on large workflows
|
||||
- OpenRouter cost tracking; analytics tag filters accept labels, groups, or `group:label`
|
||||
- Improved authenticator-2FA credential setup; PDF field mapping by positional labels
|
||||
- Copilot data-write blocks default to `continue_on_failure=false`; code-repair progress shown as quiet indicators
|
||||
|
||||
**Bug fixes**
|
||||
- Fixed the workflow builder freezing on an infinite FlowRenderer layout loop
|
||||
- Fixed the code editor crashing (stack overflow) on large or deeply-nested JSON
|
||||
- Fixed the app failing to load after a deploy from stale chunks — it now auto-recovers with a cache-busting reload
|
||||
- Fixed login credentials not reattaching after editing a workflow via MCP
|
||||
- Fixed conditional routing not being preserved when loading a v1-format workflow
|
||||
- Fixed the active run context being lost when switching organizations
|
||||
- Fixed multi-step logins not advancing when credentials filled JavaScript-gated fields
|
||||
- Fixed scanned PDFs being parsed as a single page instead of page-by-page
|
||||
- Fixed shadow-DOM text nodes being missed during element text extraction
|
||||
- Fixed runs not stopping when a configured time limit was exceeded
|
||||
- Fixed credits not recorded when data was captured inside a Navigate block
|
||||
- Fixed telephone inputs dropping digits on bare NANP-format numbers
|
||||
- Fixed persistent sessions reporting a stale cached session as live
|
||||
- Fixed FileUpload blocks silently skipping when no file was provided (now a clean no-op)
|
||||
- Fixed the block library drawer overflowing its container in the workflow builder
|
||||
BIN
docs/changelog-emails/images/2026-06-00-header.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
docs/changelog-emails/images/2026-06-01-agents-rename.png
Normal file
|
After Width: | Height: | Size: 443 KiB |
BIN
docs/changelog-emails/images/2026-06-02-workflow-studio.png
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
docs/changelog-emails/images/2026-06-03-code-first.png
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
docs/changelog-emails/images/2026-06-04-copilot.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
docs/changelog-emails/images/2026-06-05-models.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
docs/changelog-emails/images/2026-06-06-credentials.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
docs/changelog-emails/images/2026-06-07-multi-tab.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
docs/changelog-emails/images/2026-06-08-gcp-self-host.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
|
|
@ -13,6 +13,7 @@ keywords:
|
|||
<div className="cl-page">
|
||||
<div className="cl-sidebar">
|
||||
<p className="cl-sidebar-title">TIMELINE</p>
|
||||
<a href="#week-jun-30-2026" className="cl-sidebar-link">Week of Jun 30, 2026</a>
|
||||
<a href="#week-jun-22-2026" className="cl-sidebar-link">Week of Jun 22, 2026</a>
|
||||
<a href="#week-jun-15-2026" className="cl-sidebar-link">Week of Jun 15, 2026</a>
|
||||
<a href="#week-jun-8-2026" className="cl-sidebar-link">Week of Jun 8, 2026</a>
|
||||
|
|
@ -22,29 +23,44 @@ keywords:
|
|||
<a href="#week-may-11-2026" className="cl-sidebar-link">Week of May 11, 2026</a>
|
||||
<a href="#week-may-4-2026" className="cl-sidebar-link">Week of May 4, 2026</a>
|
||||
<a href="#week-apr-27-2026" className="cl-sidebar-link">Week of Apr 27, 2026</a>
|
||||
<a href="#v1-0-24" className="cl-sidebar-link">v1.0.24 — Apr 21, 2026</a>
|
||||
<a href="#v1-0-23" className="cl-sidebar-link">v1.0.23 — Apr 14, 2026</a>
|
||||
<a href="#v1-0-22" className="cl-sidebar-link">v1.0.22 — Feb 26, 2026</a>
|
||||
<a href="#v1-0-21" className="cl-sidebar-link">v1.0.21 — Feb 24, 2026</a>
|
||||
<a href="#v1-0-20" className="cl-sidebar-link">v1.0.20 — Feb 21, 2026</a>
|
||||
<a href="#v1-0-19" className="cl-sidebar-link">v1.0.19 — Feb 20, 2026</a>
|
||||
<a href="#v1-0-18" className="cl-sidebar-link">v1.0.18 — Feb 19, 2026</a>
|
||||
<a href="#v1-0-17" className="cl-sidebar-link">v1.0.17 — Feb 19, 2026</a>
|
||||
<a href="#v1-0-16" className="cl-sidebar-link">v1.0.16 — Feb 19, 2026</a>
|
||||
<a href="#v1-0-15" className="cl-sidebar-link">v1.0.15 — Feb 17, 2026</a>
|
||||
<a href="#v1-0-14" className="cl-sidebar-link">v1.0.14 — Feb 17, 2026</a>
|
||||
<a href="#v1-0-13" className="cl-sidebar-link">v1.0.13 — Feb 10, 2026</a>
|
||||
<a href="#v1-0-12" className="cl-sidebar-link">v1.0.12 — Feb 3, 2026</a>
|
||||
<a href="#v1-0-11" className="cl-sidebar-link">v1.0.11 — Jan 29, 2026</a>
|
||||
<a href="#v1-0-10" className="cl-sidebar-link">v1.0.10 — Jan 22, 2026</a>
|
||||
<a href="#v1-0-9" className="cl-sidebar-link">v1.0.9 — Jan 20, 2026</a>
|
||||
<a href="#v1-0-8" className="cl-sidebar-link">v1.0.8 — Jan 9, 2026</a>
|
||||
<a href="#week-apr-20-2026" className="cl-sidebar-link">Week of Apr 20, 2026</a>
|
||||
<a href="#week-apr-13-2026" className="cl-sidebar-link">Week of Apr 13, 2026</a>
|
||||
<a href="#week-feb-23-2026" className="cl-sidebar-link">Week of Feb 23, 2026</a>
|
||||
<a href="#week-feb-16-2026" className="cl-sidebar-link">Week of Feb 16, 2026</a>
|
||||
<a href="#week-feb-9-2026" className="cl-sidebar-link">Week of Feb 9, 2026</a>
|
||||
<a href="#week-feb-2-2026" className="cl-sidebar-link">Week of Feb 2, 2026</a>
|
||||
<a href="#week-jan-26-2026" className="cl-sidebar-link">Week of Jan 26, 2026</a>
|
||||
<a href="#week-jan-19-2026" className="cl-sidebar-link">Week of Jan 19, 2026</a>
|
||||
<a href="#week-jan-5-2026" className="cl-sidebar-link">Week of Jan 5, 2026</a>
|
||||
</div>
|
||||
|
||||
<div className="cl-content">
|
||||
<h1 className="cl-title">Changelog</h1>
|
||||
<p className="cl-subtitle">New features, improvements, and fixes in Skyvern</p>
|
||||
|
||||
<a id="week-jun-30-2026"></a>
|
||||
<Update label="Week of June 30, 2026" description="Workflow Studio UX improvements, faster enrichment, code-block self-heal, extraction accuracy, bug fixes">
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Block Type Shown in Workflow Studio Header** — The block detail panel in Workflow Studio now displays the block type in the header, making it easier to identify what kind of block you're editing at a glance. ([#6956](https://github.com/Skyvern-AI/skyvern/pull/6956))
|
||||
- **Improved Extraction Accuracy for Absent Data** — The agent now better handles cases where requested output is genuinely absent from the page, reducing false positives in extraction results. ([#6957](https://github.com/Skyvern-AI/skyvern/pull/6957))
|
||||
- **Data-Only Validation Skips Page Evidence** — Validation steps that check only extracted data no longer capture page screenshots when they aren't needed, reducing unnecessary work during runs. ([#6960](https://github.com/Skyvern-AI/skyvern/pull/6960))
|
||||
- **Copilot Block Runs Routed to UI Worker** — Copilot block runs are now dispatched to a dedicated UI worker, keeping the editor responsive during live automation. ([#6962](https://github.com/Skyvern-AI/skyvern/pull/6962))
|
||||
- **Clearer Invalid JSON Errors in Workflow Editor** — The workflow editor now surfaces a descriptive error when a parameter contains invalid JSON, making it faster to diagnose and fix configuration mistakes. ([#6963](https://github.com/Skyvern-AI/skyvern/pull/6963))
|
||||
- **"Ignore System Prompt" Moved to Advanced Settings** — The "Ignore system prompt" option has been relocated to the advanced settings panel, reducing clutter in the default block configuration view. ([#6964](https://github.com/Skyvern-AI/skyvern/pull/6964))
|
||||
- **Empty Extra HTTP Headers Hidden** — Extra HTTP header fields that are empty no longer appear in block configurations, reducing visual noise in the workflow editor. ([#6966](https://github.com/Skyvern-AI/skyvern/pull/6966))
|
||||
- **Block Selection Persisted in URL** — The selected block in Workflow Studio is now encoded in the URL, so your selection is preserved on reload and can be shared via link. ([#6969](https://github.com/Skyvern-AI/skyvern/pull/6969))
|
||||
- **Code-Block Self-Heal Minimum Retry Floor** — The code-block self-healing loop now enforces a minimum number of repair attempts before giving up, preventing premature failures on recoverable errors. ([#6971](https://github.com/Skyvern-AI/skyvern/pull/6971))
|
||||
- **Faster Live-Draft Enrichment** — Workflow recording live-draft enrichment now uses a faster model and a shorter debounce interval, making draft suggestions appear noticeably sooner while recording. ([#6973](https://github.com/Skyvern-AI/skyvern/pull/6973))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed syntax highlighting disappearing when viewing large webhook payloads. ([#6967](https://github.com/Skyvern-AI/skyvern/pull/6967))
|
||||
- Fixed LLM blocks not enforcing their configured output schema, which could allow malformed data to propagate to downstream blocks. ([#6965](https://github.com/Skyvern-AI/skyvern/pull/6965))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="week-jun-22-2026"></a>
|
||||
<Update label="Week of June 22, 2026" description="Workflow Studio preview, new models, label management, Gmail OTP, Copilot improvements, analytics filters, bug fixes">
|
||||
|
||||
|
|
@ -419,8 +435,8 @@ keywords:
|
|||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-24"></a>
|
||||
<Update label="v1.0.24 — April 21, 2026" description="Copilot reliability, run recordings, 2FA improvements, and more">
|
||||
<a id="week-apr-20-2026"></a>
|
||||
<Update label="Week of April 20, 2026" description="Copilot reliability, run recordings, 2FA improvements, and more">
|
||||
|
||||
## Improvements
|
||||
|
||||
|
|
@ -437,8 +453,8 @@ keywords:
|
|||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-23"></a>
|
||||
<Update label="v1.0.23 — April 14, 2026" description="Saudi Arabia proxy, workflow error mapping, smarter caching, MCP OAuth, AI output summaries, and reliability fixes">
|
||||
<a id="week-apr-13-2026"></a>
|
||||
<Update label="Week of April 13, 2026" description="Saudi Arabia proxy, workflow error mapping, smarter caching, MCP OAuth, AI output summaries, and reliability fixes">
|
||||
|
||||
## New features
|
||||
|
||||
|
|
@ -482,38 +498,35 @@ keywords:
|
|||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-22"></a>
|
||||
<Update label="v1.0.22 — February 26, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, and more">
|
||||
<a id="week-feb-23-2026"></a>
|
||||
<Update label="Week of February 23, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, reserved parameters, new LLM models">
|
||||
|
||||
## New features
|
||||
|
||||
- **Adaptive Caching (Script Generation)** — Skyvern can now learn from repeated workflow runs and generate cached scripts that speed up future executions. When a block runs successfully, Skyvern records a reusable script and replays it on subsequent runs, falling back to the AI agent only if the script fails. ([#4908](https://github.com/Skyvern-AI/skyvern/pull/4908), [#4916](https://github.com/Skyvern-AI/skyvern/pull/4916), [#4917](https://github.com/Skyvern-AI/skyvern/pull/4917), [#4920](https://github.com/Skyvern-AI/skyvern/pull/4920), [#4922](https://github.com/Skyvern-AI/skyvern/pull/4922), [#4931](https://github.com/Skyvern-AI/skyvern/pull/4931))
|
||||
|
||||
- **Workflow Trigger Block** — A new block type that lets one workflow trigger another from within the workflow editor. Chain workflows together as building blocks for complex automations. ([#4885](https://github.com/Skyvern-AI/skyvern/pull/4885))
|
||||
|
||||
- **Browser-based CLI Signup** — New users can sign up for Skyvern Cloud directly from the CLI. Running `skyvern signup` opens a browser flow that creates your account and stores your API key locally. ([#4925](https://github.com/Skyvern-AI/skyvern/pull/4925))
|
||||
|
||||
- **Interactive ngrok Tunnel** — `skyvern browser serve` can now create an ngrok tunnel automatically, making it easy to expose your local browser to Skyvern Cloud without manual network configuration. ([#4924](https://github.com/Skyvern-AI/skyvern/pull/4924))
|
||||
|
||||
- **South Korea Proxy Location** — Added `RESIDENTIAL_KR` as a proxy geolocation option for automations targeting South Korean websites. ([#4918](https://github.com/Skyvern-AI/skyvern/pull/4918))
|
||||
|
||||
- **Downloaded Files Tab** — Browser session detail pages now include a Downloaded Files tab for viewing and accessing files captured during a session. ([#4911](https://github.com/Skyvern-AI/skyvern/pull/4911))
|
||||
|
||||
- **CDP Proxy Authentication** — Remote browser connections now support authenticated proxies via the CDP `Fetch.authRequired` protocol. ([#4936](https://github.com/Skyvern-AI/skyvern/pull/4936))
|
||||
|
||||
- **Remote Browser Download Interception** — File downloads triggered by XHR requests and other non-navigation events are now captured when using remote CDP browser connections. ([#4906](https://github.com/Skyvern-AI/skyvern/pull/4906), [#4921](https://github.com/Skyvern-AI/skyvern/pull/4921), [#4934](https://github.com/Skyvern-AI/skyvern/pull/4934))
|
||||
- **`current_date` Reserved Parameter** — Workflows now have a built-in `current_date` parameter that resolves to today's date automatically. ([#4854](https://github.com/Skyvern-AI/skyvern/pull/4854))
|
||||
- **Reserved Parameters in Block Editor** — The workflow block parameter picker now shows reserved system parameters, making them discoverable without memorizing names. ([#4857](https://github.com/Skyvern-AI/skyvern/pull/4857))
|
||||
- **Natural Language Loop `data_schema`** — Loop blocks driven by natural language extraction now support a `data_schema` field for consistent output structure. ([#4851](https://github.com/Skyvern-AI/skyvern/pull/4851))
|
||||
- **Gemini 3.1 Pro and Inception Mercury-2** — Two new LLM engine options for your automations. ([#4847](https://github.com/Skyvern-AI/skyvern/pull/4847))
|
||||
- **MCP Server on API Service** — The MCP remote server is now mounted at `/mcp` on the existing API, eliminating the need for a separate process. ([#4843](https://github.com/Skyvern-AI/skyvern/pull/4843))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Renamed "Login-Free" to "Saved Profile"** — Clearer terminology throughout the UI. Browser session checkbox wording has also been improved. ([#4914](https://github.com/Skyvern-AI/skyvern/pull/4914))
|
||||
|
||||
- **TOTP Verification Improvements** — Better timer handling, expired code retry, 2FA banner suppression, and more reliable goal-text extraction during TOTP flows. ([#4860](https://github.com/Skyvern-AI/skyvern/pull/4860))
|
||||
|
||||
- **TOTP Webhook includes `workflow_permanent_id`** — Easier to identify which workflow is requesting a 2FA code. ([#4871](https://github.com/Skyvern-AI/skyvern/pull/4871))
|
||||
|
||||
- **Consolidated Gemini 3 Pro Model Key** — `VERTEX_GEMINI_3_PRO` now auto-resolves to the latest version, so you no longer need to update config when Google releases point versions. ([#4926](https://github.com/Skyvern-AI/skyvern/pull/4926))
|
||||
|
||||
- **Browser Rotation for Remote Connections** — Remote browser environments now support context rotation, improving reliability for long-running automations. ([#4929](https://github.com/Skyvern-AI/skyvern/pull/4929))
|
||||
- Workflow save validation (HTTP 422) now returns clear, field-level error messages. ([#4852](https://github.com/Skyvern-AI/skyvern/pull/4852))
|
||||
- Browser launch errors now show actionable messages instead of raw exceptions. ([#4844](https://github.com/Skyvern-AI/skyvern/pull/4844))
|
||||
- Cloud LLM fallback overrides are now properly applied when selecting models. ([#4839](https://github.com/Skyvern-AI/skyvern/pull/4839))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
|
|
@ -525,151 +538,53 @@ keywords:
|
|||
- MCP remote auth token comparison no longer fails on encrypted tokens. ([#4863](https://github.com/Skyvern-AI/skyvern/pull/4863))
|
||||
- `skyvern quickstart` now handles local PostgreSQL without a pre-existing `skyvern` role. ([#4878](https://github.com/Skyvern-AI/skyvern/pull/4878))
|
||||
- WebSocket URLs through ngrok tunnels now rewrite correctly for live browser streaming. ([#4927](https://github.com/Skyvern-AI/skyvern/pull/4927))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-21"></a>
|
||||
<Update label="v1.0.21 — February 24, 2026" description="Reserved parameters, data_schema for loops, new LLM models">
|
||||
|
||||
## New features
|
||||
|
||||
- **`current_date` Reserved Parameter** — Workflows now have a built-in `current_date` parameter that resolves to today's date automatically. ([#4854](https://github.com/Skyvern-AI/skyvern/pull/4854))
|
||||
|
||||
- **Reserved Parameters in Block Editor** — The workflow block parameter picker now shows reserved system parameters, making them discoverable without memorizing names. ([#4857](https://github.com/Skyvern-AI/skyvern/pull/4857))
|
||||
|
||||
- **Natural Language Loop `data_schema`** — Loop blocks driven by natural language extraction now support a `data_schema` field for consistent output structure. ([#4851](https://github.com/Skyvern-AI/skyvern/pull/4851))
|
||||
|
||||
- **Gemini 3.1 Pro and Inception Mercury-2** — Two new LLM engine options for your automations. ([#4847](https://github.com/Skyvern-AI/skyvern/pull/4847))
|
||||
|
||||
- **MCP Server on API Service** — The MCP remote server is now mounted at `/mcp` on the existing API, eliminating the need for a separate process. ([#4843](https://github.com/Skyvern-AI/skyvern/pull/4843))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Workflow save validation (HTTP 422) now returns clear, field-level error messages. ([#4852](https://github.com/Skyvern-AI/skyvern/pull/4852))
|
||||
- Browser launch errors now show actionable messages instead of raw exceptions. ([#4844](https://github.com/Skyvern-AI/skyvern/pull/4844))
|
||||
- Cloud LLM fallback overrides are now properly applied when selecting models. ([#4839](https://github.com/Skyvern-AI/skyvern/pull/4839))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Conditional blocks no longer crash when all branches use Jinja templates. ([#4849](https://github.com/Skyvern-AI/skyvern/pull/4849))
|
||||
- Conditional blocks no longer misinterpret resolved Jinja template variables. ([#4841](https://github.com/Skyvern-AI/skyvern/pull/4841))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-20"></a>
|
||||
<Update label="v1.0.20 — February 21, 2026" description="Browser profile testing UI and 2FA verification flow">
|
||||
<a id="week-feb-16-2026"></a>
|
||||
<Update label="Week of February 16, 2026" description="Unified Browser Task block, browser profile testing, 2FA detection, Skills package, Claude Opus 4.6, MCP block discovery">
|
||||
|
||||
## New features
|
||||
|
||||
- **Browser Profile Testing UI (Frontend)** — The Browsers page now lets you launch a test run against a saved browser profile to confirm its login state is still valid before scheduling production workflows. ([#4833](https://github.com/Skyvern-AI/skyvern/pull/4833))
|
||||
|
||||
- **2FA Verification Code UI and Notifications** — When a workflow pauses for a 2FA code, the UI now shows a clear prompt and pushes a notification so you don't miss it. ([#4787](https://github.com/Skyvern-AI/skyvern/pull/4787))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **2FA Backend Cleanup** — Refactored the 2FA pipeline to make code delivery more reliable across both the UI form and the API endpoint. ([#4826](https://github.com/Skyvern-AI/skyvern/pull/4826))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-19"></a>
|
||||
<Update label="v1.0.19 — February 20, 2026" description="Admin impersonation controls and configuration improvements">
|
||||
|
||||
## New features
|
||||
|
||||
- **Gated Admin Impersonation for MCP API Keys** — Adds gated admin controls so support engineers can impersonate an organization scope when troubleshooting MCP-based automations, with audit-friendly access controls. ([#4822](https://github.com/Skyvern-AI/skyvern/pull/4822))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Environment Variables Override Mounted API Keys** — In Skyvern's Streamlit-based deployment, env vars now take precedence over keys baked into the image, making local overrides simpler. ([#4824](https://github.com/Skyvern-AI/skyvern/pull/4824))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-18"></a>
|
||||
<Update label="v1.0.18 — February 19, 2026" description="Browser profile testing and saved-profile workflows">
|
||||
|
||||
## New features
|
||||
|
||||
- **Browser Profile Testing** — Test browser profiles directly from the UI to verify that saved login sessions are still valid. Workflows can also run with a saved browser profile, enabling login-free automations that reuse authenticated sessions. ([#4818](https://github.com/Skyvern-AI/skyvern/pull/4818))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- When both a TOTP credential and a webhook are configured for 2FA, the credential is now correctly prioritized. ([#4811](https://github.com/Skyvern-AI/skyvern/pull/4811))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-17"></a>
|
||||
<Update label="v1.0.17 — February 19, 2026" description="Skills package and cached script management">
|
||||
|
||||
## New features
|
||||
|
||||
- **Skyvern Skills Package** — The new `skyvern skill` CLI command provides pre-built workflow templates and reference documentation for common automation patterns. ([#4817](https://github.com/Skyvern-AI/skyvern/pull/4817))
|
||||
|
||||
- **Clear Cached Scripts API** — A new endpoint lets you clear cached automation scripts for a workflow when a target website changes and you want to force Skyvern to re-learn. ([#4809](https://github.com/Skyvern-AI/skyvern/pull/4809))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-16"></a>
|
||||
<Update label="v1.0.16 — February 19, 2026" description="2FA detection, full CLI parity, Claude Opus 4.6 CUA">
|
||||
|
||||
## New features
|
||||
|
||||
- **Automatic 2FA Detection Without TOTP Credentials** — Skyvern now detects when a website asks for a 2FA code even without pre-configured TOTP credentials. The system pauses and waits for you to provide the code via the UI or webhook. ([#4786](https://github.com/Skyvern-AI/skyvern/pull/4786))
|
||||
|
||||
- **Full CLI Parity with MCP** — The CLI now supports browser session management, credential management, block operations, and enhanced workflow commands. Everything available through MCP is now accessible from the command line. ([#4789](https://github.com/Skyvern-AI/skyvern/pull/4789), [#4792](https://github.com/Skyvern-AI/skyvern/pull/4792), [#4793](https://github.com/Skyvern-AI/skyvern/pull/4793))
|
||||
|
||||
- **Claude Opus 4.6 CUA Support** — Anthropic's Claude Opus 4.6 is now available as a Computer Use Agent model for driving browser interactions. ([#4780](https://github.com/Skyvern-AI/skyvern/pull/4780))
|
||||
|
||||
## Improvements
|
||||
|
||||
- The workflow run timeline now properly displays loop iterations and conditional branches, making complex runs easier to debug. ([#4782](https://github.com/Skyvern-AI/skyvern/pull/4782))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed conditional blocks evaluating the wrong value after Jinja template rendering. ([#4801](https://github.com/Skyvern-AI/skyvern/pull/4801))
|
||||
- Fixed MFA resolution priority when both TOTP credentials and webhooks are configured. ([#4800](https://github.com/Skyvern-AI/skyvern/pull/4800))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-15"></a>
|
||||
<Update label="v1.0.15 — February 17, 2026" description="Claude Opus 4.6 model support">
|
||||
|
||||
## New features
|
||||
|
||||
- **Anthropic Claude Opus 4.6** — Added Claude Opus 4.6 as an available LLM engine for web automation tasks. ([#4777](https://github.com/Skyvern-AI/skyvern/pull/4777), [#4778](https://github.com/Skyvern-AI/skyvern/pull/4778))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-14"></a>
|
||||
<Update label="v1.0.14 — February 17, 2026" description="Unified Browser Task block, MCP block discovery, and credential editing">
|
||||
|
||||
## New features
|
||||
|
||||
- **Unified Browser Task Block** — The Navigation V1 and Task V2 blocks have been merged into a single **Browser Task** block. The block now exposes both engines as a dropdown, simplifying the workflow editor and reducing block-type confusion. ([#4695](https://github.com/Skyvern-AI/skyvern/pull/4695))
|
||||
|
||||
- **Block Discovery MCP Tools** — Added MCP tools that let AI assistants enumerate available block types and their schemas, plus richer instructions for building workflows through MCP. ([#4683](https://github.com/Skyvern-AI/skyvern/pull/4683))
|
||||
|
||||
- **Edit Existing Credentials API** — A new endpoint lets you update credential values without deleting and recreating them, preserving credential IDs across changes. ([#4693](https://github.com/Skyvern-AI/skyvern/pull/4693))
|
||||
|
||||
- **Filter Workflow Runs by Error Code and Search Key** — The workflow runs list endpoint now supports `error_code` and `search_key` query parameters for narrowing down failed runs. ([#4694](https://github.com/Skyvern-AI/skyvern/pull/4694))
|
||||
|
||||
- **GPT-5 Mini in Model Selector** — GPT-5 Mini is now selectable as an LLM engine. GPT-5 has been hidden in favor of more reliable variants. ([#4686](https://github.com/Skyvern-AI/skyvern/pull/4686))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **2FA Backend Cleanup** — Refactored the 2FA pipeline to make code delivery more reliable across both the UI form and the API endpoint. ([#4826](https://github.com/Skyvern-AI/skyvern/pull/4826))
|
||||
- **Environment Variables Override Mounted API Keys** — In Skyvern's Streamlit-based deployment, env vars now take precedence over keys baked into the image, making local overrides simpler. ([#4824](https://github.com/Skyvern-AI/skyvern/pull/4824))
|
||||
- The workflow run timeline now properly displays loop iterations and conditional branches, making complex runs easier to debug. ([#4782](https://github.com/Skyvern-AI/skyvern/pull/4782))
|
||||
- **Block Failure Reasons Surfaced in UI** — Every block type now shows its `failure_reason` in the run timeline, making debugging non-obvious failures faster. ([#4672](https://github.com/Skyvern-AI/skyvern/pull/4672))
|
||||
|
||||
- **Webhook Payload Truncation in Logs** — Large webhook payloads are now truncated in log entries, keeping logs readable without losing context. ([#4701](https://github.com/Skyvern-AI/skyvern/pull/4701))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- When both a TOTP credential and a webhook are configured for 2FA, the credential is now correctly prioritized. ([#4811](https://github.com/Skyvern-AI/skyvern/pull/4811))
|
||||
- Fixed conditional blocks evaluating the wrong value after Jinja template rendering. ([#4801](https://github.com/Skyvern-AI/skyvern/pull/4801))
|
||||
- Fixed MFA resolution priority when both TOTP credentials and webhooks are configured. ([#4800](https://github.com/Skyvern-AI/skyvern/pull/4800))
|
||||
- Fixed the cursor jumping to the end while editing conditional expressions in the workflow editor. ([#4682](https://github.com/Skyvern-AI/skyvern/pull/4682))
|
||||
- Fixed dotted Jinja variables (e.g. `{{ block_output.field }}`) rendering incorrectly in the expression preview. ([#4684](https://github.com/Skyvern-AI/skyvern/pull/4684))
|
||||
- The API now returns 404 instead of 500 when a workflow is not found. ([#4699](https://github.com/Skyvern-AI/skyvern/pull/4699))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-13"></a>
|
||||
<Update label="v1.0.13 — February 10, 2026" description="Cached scripts for control-flow blocks, OTEL, and workflow editor polish">
|
||||
<a id="week-feb-9-2026"></a>
|
||||
<Update label="Week of February 9, 2026" description="Cached scripts for control-flow blocks, OTEL, and workflow editor polish">
|
||||
|
||||
## New features
|
||||
|
||||
|
|
@ -699,8 +614,8 @@ keywords:
|
|||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-12"></a>
|
||||
<Update label="v1.0.12 — February 3, 2026" description="Task V2 termination, ForLoop caching, and cleaner conditional debugging">
|
||||
<a id="week-feb-2-2026"></a>
|
||||
<Update label="Week of February 2, 2026" description="Task V2 termination, ForLoop caching, and cleaner conditional debugging">
|
||||
|
||||
## New features
|
||||
|
||||
|
|
@ -732,8 +647,8 @@ keywords:
|
|||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-11"></a>
|
||||
<Update label="v1.0.11 — January 29, 2026" description="Workflow Copilot GA, Browser Sessions v2 frontend, and TOTP improvements">
|
||||
<a id="week-jan-26-2026"></a>
|
||||
<Update label="Week of January 26, 2026" description="Workflow Copilot GA, Browser Sessions v2 frontend, and TOTP improvements">
|
||||
|
||||
## New features
|
||||
|
||||
|
|
@ -763,26 +678,32 @@ keywords:
|
|||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-10"></a>
|
||||
<Update label="v1.0.10 — January 22, 2026" description="Browser Sessions v2 backend, general text CAPTCHA, and TOTP webhook retries">
|
||||
<a id="week-jan-19-2026"></a>
|
||||
<Update label="Week of January 19, 2026" description="Browser Sessions v2, Print PDF block, Copilot streaming, general text CAPTCHA, HTTP file downloads">
|
||||
|
||||
## New features
|
||||
|
||||
- **Browser Sessions v2 (Backend)** — A new persistent browser session architecture with cleaner lifecycle management, better resource reuse, and improved reliability for long-running automations. The frontend follows in v1.0.11. ([#4515](https://github.com/Skyvern-AI/skyvern/pull/4515))
|
||||
|
||||
- **General Text CAPTCHA Solver** — Skyvern can now solve a wider range of text-based CAPTCHAs without site-specific configuration. ([#4517](https://github.com/Skyvern-AI/skyvern/pull/4517))
|
||||
|
||||
- **TOTP Webhook Retries** — TOTP webhook delivery now retries automatically on transient failures, reducing the rate of "no code received" errors during 2FA flows. ([#4518](https://github.com/Skyvern-AI/skyvern/pull/4518))
|
||||
|
||||
- **Block-Label Validation in `run_blocks`** — The `run_blocks` endpoint now validates block labels up front, returning a clear error instead of failing partway through execution. ([#4500](https://github.com/Skyvern-AI/skyvern/pull/4500))
|
||||
- **Print PDF Block** — A new workflow block that prints the current page to PDF and saves it to your run's downloaded files, useful for archiving receipts, invoices, and reports. ([#4452](https://github.com/Skyvern-AI/skyvern/pull/4452))
|
||||
- **HTTP File Downloads** — The HTTP Request block can now download files directly via HTTP (not just GET JSON), enabling workflows that pull artifacts from authenticated APIs. ([#4440](https://github.com/Skyvern-AI/skyvern/pull/4440))
|
||||
- **Workflow Copilot Streaming with Cancel** — The Workflow Copilot now streams its responses with a cancel button, so you can stop generation mid-stream if you want to refine your prompt. ([#4437](https://github.com/Skyvern-AI/skyvern/pull/4437), [#4456](https://github.com/Skyvern-AI/skyvern/pull/4456))
|
||||
- **"Execute on Any Outcome" (Finally) Block Option** — Blocks can now be configured to run regardless of whether prior blocks succeeded, enabling cleanup steps and "always send report" workflows. ([#4443](https://github.com/Skyvern-AI/skyvern/pull/4443))
|
||||
- **Persistent Browser Session Uptime Billing** — Billing v2 now meters persistent browser session uptime separately, with PostHog feature flags controlling rollout. ([#4444](https://github.com/Skyvern-AI/skyvern/pull/4444))
|
||||
- **GPT-5 Mini Flex Engine** — Added GPT-5 Mini Flex as an available LLM engine. ([#4432](https://github.com/Skyvern-AI/skyvern/pull/4432))
|
||||
- **Clipboard Copy over HTTP** — The browser now exposes clipboard copy functionality that works in HTTP-served browser sessions, not just HTTPS. ([#4446](https://github.com/Skyvern-AI/skyvern/pull/4446))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Confirmation Dialog Shows Affected Blocks** — Deleting a parameter or block now lists every block that references it, so you don't accidentally break downstream logic. ([#4519](https://github.com/Skyvern-AI/skyvern/pull/4519))
|
||||
|
||||
- **Workflow Updates Return 409 on Race** — Concurrent workflow updates now return HTTP 409 instead of silently overwriting each other. ([#4510](https://github.com/Skyvern-AI/skyvern/pull/4510))
|
||||
|
||||
- **Tiktoken Caching** — Tokenizer instances are now cached, cutting per-step overhead on token counting. ([#4521](https://github.com/Skyvern-AI/skyvern/pull/4521))
|
||||
- **HTTP Block Boolean and Integer Templates** — The HTTP Request block now correctly passes boolean and integer template values without coercing them to strings. ([#4435](https://github.com/Skyvern-AI/skyvern/pull/4435))
|
||||
- **Bitwarden and Azure Vault Tooltips** — Added inline help tooltips explaining how to set up Bitwarden and Azure Key Vault credential sources. ([#4447](https://github.com/Skyvern-AI/skyvern/pull/4447))
|
||||
- **Smaller S3 Objects Use STANDARD Tier** — Storage cost optimization: small artifacts now use STANDARD instead of GLACIER_IR. ([#4453](https://github.com/Skyvern-AI/skyvern/pull/4453))
|
||||
- **Reduced Max SVG Parsing** — Lowered the SVG parsing cap to prevent runaway memory usage on pathological pages. ([#4430](https://github.com/Skyvern-AI/skyvern/pull/4430))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
|
|
@ -792,39 +713,8 @@ keywords:
|
|||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-9"></a>
|
||||
<Update label="v1.0.9 — January 20, 2026" description="Print PDF block, Workflow Copilot streaming, and persistent session billing">
|
||||
|
||||
## New features
|
||||
|
||||
- **Print PDF Block** — A new workflow block that prints the current page to PDF and saves it to your run's downloaded files, useful for archiving receipts, invoices, and reports. ([#4452](https://github.com/Skyvern-AI/skyvern/pull/4452))
|
||||
|
||||
- **HTTP File Downloads** — The HTTP Request block can now download files directly via HTTP (not just GET JSON), enabling workflows that pull artifacts from authenticated APIs. ([#4440](https://github.com/Skyvern-AI/skyvern/pull/4440))
|
||||
|
||||
- **Workflow Copilot Streaming with Cancel** — The Workflow Copilot now streams its responses with a cancel button, so you can stop generation mid-stream if you want to refine your prompt. ([#4437](https://github.com/Skyvern-AI/skyvern/pull/4437), [#4456](https://github.com/Skyvern-AI/skyvern/pull/4456))
|
||||
|
||||
- **"Execute on Any Outcome" (Finally) Block Option** — Blocks can now be configured to run regardless of whether prior blocks succeeded, enabling cleanup steps and "always send report" workflows. ([#4443](https://github.com/Skyvern-AI/skyvern/pull/4443))
|
||||
|
||||
- **Persistent Browser Session Uptime Billing** — Billing v2 now meters persistent browser session uptime separately, with PostHog feature flags controlling rollout. ([#4444](https://github.com/Skyvern-AI/skyvern/pull/4444))
|
||||
|
||||
- **GPT-5 Mini Flex Engine** — Added GPT-5 Mini Flex as an available LLM engine. ([#4432](https://github.com/Skyvern-AI/skyvern/pull/4432))
|
||||
|
||||
- **Clipboard Copy over HTTP** — The browser now exposes clipboard copy functionality that works in HTTP-served browser sessions, not just HTTPS. ([#4446](https://github.com/Skyvern-AI/skyvern/pull/4446))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **HTTP Block Boolean and Integer Templates** — The HTTP Request block now correctly passes boolean and integer template values without coercing them to strings. ([#4435](https://github.com/Skyvern-AI/skyvern/pull/4435))
|
||||
|
||||
- **Bitwarden and Azure Vault Tooltips** — Added inline help tooltips explaining how to set up Bitwarden and Azure Key Vault credential sources. ([#4447](https://github.com/Skyvern-AI/skyvern/pull/4447))
|
||||
|
||||
- **Smaller S3 Objects Use STANDARD Tier** — Storage cost optimization: small artifacts now use STANDARD instead of GLACIER_IR. ([#4453](https://github.com/Skyvern-AI/skyvern/pull/4453))
|
||||
|
||||
- **Reduced Max SVG Parsing** — Lowered the SVG parsing cap to prevent runaway memory usage on pathological pages. ([#4430](https://github.com/Skyvern-AI/skyvern/pull/4430))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="v1-0-8"></a>
|
||||
<Update label="v1.0.8 — January 9, 2026" description="Workflow Copilot v1, Azure secret storage, and per-action billing">
|
||||
<a id="week-jan-5-2026"></a>
|
||||
<Update label="Week of January 5, 2026" description="Workflow Copilot v1, Azure secret storage, and per-action billing">
|
||||
|
||||
## New features
|
||||
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ Restart Skyvern after setting these variables.
|
|||
| Status stays Inactive | Verify the API base URL is a valid URL and the API token is not empty. The configuration is validated on save but does not make a live request to your server. |
|
||||
| Credentials not created | Review your API logs for auth errors. Ensure the response includes an `id` field. Skyvern expects HTTP 200 for all operations. |
|
||||
| Credentials not retrieved | Ensure the GET response includes all required fields for the credential type (`username` and `password` for passwords, all card fields for credit cards, `secret_value` for secrets). |
|
||||
| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. The default vault type is `bitwarden`, so this variable must be explicitly set. |
|
||||
| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. Deployment defaults vary, so `custom` must be explicitly set for an external service. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
306
docs/design/2026-06-15-workflow-build-recent-activity-design.md
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
# Workflow Build — Recent Activity History (SKY-11075)
|
||||
|
||||
Replace the bottom-of-canvas **dot/carousel indicator** on `/workflows/:id/build` with a
|
||||
real recent-activity history view that's easy to scan and lets you jump directly to a
|
||||
specific recent activity.
|
||||
|
||||
Status: **Final — recent activity is a compact run selector locked to the right
|
||||
run-detail panel; the A/B/C style and placement preview toggles were removed. See
|
||||
the Round 7 section below.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Frame
|
||||
|
||||
- **Page:** Workflow editor, *build* mode — `/workflows/:id/build` (and the run-loaded
|
||||
variant `/workflows/:wpid/:workflowRunId/:blockLabel/build`). The history surface only
|
||||
renders in **debug/browser mode** (`showBrowser`), bottom-center over the canvas+code
|
||||
pane. Redesign of an existing affordance.
|
||||
- **Job to be done:** While iterating on a workflow you fire blocks/the workflow many times
|
||||
in one debug session. Today those runs collapse into a row of identical ~16px dots whose
|
||||
only metadata is a hover tooltip. You can't tell at a glance *what* ran, *whether it
|
||||
passed*, *how long it took*, or *which mode* (agent vs cached code) — you must hover each
|
||||
dot one by one. The job: make each recent run legible at a glance and jump straight to it.
|
||||
- **Deciding constraint — what counts as "recent activity":** **Debug-session runs**
|
||||
(confirmed with requester). Each entry = one block/workflow run triggered in the current
|
||||
debug session, sourced from `GET /debug-session/{id}/runs` (`useDebugSessionRunsQuery`).
|
||||
No new backend. Deliberately distinct from (a) the global **Runs** page and (b) the
|
||||
right-side **run-detail timeline** (`DebuggerRun`/`DebuggerRunTimeline`) which shows the
|
||||
*inside* of one selected run. This surface is the *list of runs*; selecting one drives the
|
||||
existing detail rail — no overlapping history surfaces.
|
||||
- **Open sub-question (requester):** is a dock/strip the best surface, or do we want a
|
||||
"proper timeline"? → resolved via the concepts below; requester to pick direction.
|
||||
- **Platform:** web.
|
||||
- **North star references:** Databricks pipeline run-selector + event log; Vercel Activity feed.
|
||||
|
||||
### Data available per entry (`DebugSessionRun`) — today all hidden behind one dot
|
||||
`block_label`, `status` (running/completed/failed/terminated/timed_out/canceled/queued/
|
||||
skipped), `created_at` / `queued_at` / `started_at` / `finished_at` (→ duration + "x ago"),
|
||||
`failure_reason`, `run_with` (agent | code), `code_gen`, `ai_fallback`, `workflow_run_id`,
|
||||
`workflow_permanent_id`, `script_run_id`. Selecting → `navigate(/workflows/{wpid}/{workflow_run_id}/{block_label}/build)`.
|
||||
|
||||
---
|
||||
|
||||
## 2. DS inventory (read from code — Figma DS is thin by design)
|
||||
|
||||
| Need | Reuse (exists in repo) |
|
||||
|---|---|
|
||||
| Status glyph | `StatusDot` pattern (WorkflowRunTimelineBlockItem): `CheckCircledIcon text-success`, `CrossCircledIcon text-destructive`, `ReloadIcon animate-spin text-sky-400`, neutral `rounded-full bg-slate-600` |
|
||||
| Block-type glyph | `WorkflowBlockIcon` (per `WorkflowBlockType`) |
|
||||
| Type / mode pills | inline pill convention `rounded bg-slate-700/70 px-1.5 py-0.5 text-[10px] font-medium` |
|
||||
| Relative time / duration | `formatMs(Date.now()-created).ago`, `formatDuration(toDuration(ms))`, `tabular-nums` |
|
||||
| Tooltip | `Tip` (`@/components/Tip`) |
|
||||
| Scroll container | `ScrollArea` (`@/components/ui/scroll-area`) |
|
||||
| Dropdown / flyout | `Popover` (`@/components/ui/popover`) |
|
||||
| Expand / collapse | `Collapsible` (`@/components/ui/collapsible`) |
|
||||
| Buttons / badges | `Button`, `Badge` |
|
||||
| Surface tokens | `bg-neutral-50 dark:bg-background`, `border-neutral-200 dark:border-neutral-800`, focus `focus-visible:ring-1 focus-visible:ring-white/40` |
|
||||
|
||||
Conventions: build-mode rails skew dark (`slate-*`); pills `text-[10px]`, `tabular-nums` for
|
||||
numbers; every interactive row is a `<button>` with a visible focus ring.
|
||||
|
||||
## 3. Gap ledger (DS lacks → candidate Step-5 tickets)
|
||||
- No dedicated **Status badge** component — status color/icon mapping is re-derived inline in
|
||||
several places (`StatusDot`, run cards). Candidate: extract a shared `RunStatusBadge`.
|
||||
- No **horizontal "activity strip"/run-history rail** primitive.
|
||||
- (Append as concepts/implementation pressure the library.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Inspiration (Mobbin — web)
|
||||
|
||||
1. **Databricks — pipeline "Update details"** — editor canvas with a **Run: {timestamp}
|
||||
[status]** selector top-left, a detail panel (Update ID, *Status, Duration, Start time,
|
||||
Run time, Completion date*), and a timestamped **Event log** below. Closest analog: a
|
||||
build canvas whose run history is a compact selector + per-run metadata.
|
||||
[link](https://mobbin.com/screens/2a53f9eb-b8e1-4958-afb7-f441acbdf414). a11y: the
|
||||
selector must be a real listbox/menu button, not a click-only div.
|
||||
2. **Vercel — Activity** — scannable vertical feed: each row = entity icon + plain-language
|
||||
description + **relative time** ("42m"), grouped by date, left filter rail. Gold standard
|
||||
for "legible at a glance."
|
||||
[link](https://mobbin.com/screens/2f3056eb-a31a-4b3e-ae4b-1bcc1352117f). a11y: relative
|
||||
time needs a `title`/`<time datetime>` with the absolute timestamp.
|
||||
3. **WorkOS — Events** — master-detail: list (event + date/time) on the left, metadata on the
|
||||
right. [link](https://mobbin.com/screens/a5b50327-60e0-4075-9cfd-7f383d18265c).
|
||||
4. **Okta — System Log** / **Stripe — Events** / **Cloudflare — Observability** — dense log
|
||||
tables (timestamp · type · message), some with a count-over-time bar.
|
||||
[Okta](https://mobbin.com/screens/70f42b3f-1823-4cb2-872d-346ad3eab8dc) ·
|
||||
[Stripe](https://mobbin.com/screens/5423e185-8374-4b6c-a4a0-2d3ae9adc1d0) ·
|
||||
[Cloudflare](https://mobbin.com/screens/db64bfde-eac6-4786-8c29-62b98eb1a149). a11y:
|
||||
color-coded status must pair with an icon/text, not color alone.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concepts
|
||||
|
||||
All three keep the data scope (debug-session runs), keep the existing bottom-center anchor
|
||||
over the canvas+code pane, and preserve the existing select→navigate behavior. They differ in
|
||||
*how the history is surfaced*.
|
||||
|
||||
### Concept A — "Activity Strip" (in-place horizontal chips)
|
||||
- **Thesis:** smallest change. Swap the row of bare dots for a row of legible **chips** in the
|
||||
same pill, scrolling horizontally. Each chip: status glyph + block icon + block label +
|
||||
"x ago"; duration/mode in tooltip. Click → navigate.
|
||||
- **Lineage:** Databricks run-selector (canvas-anchored) + Vercel chip legibility.
|
||||
- **DS:** `StatusDot`, `WorkflowBlockIcon`, pills, `ScrollArea`, `formatMs`. Same wrapper/position.
|
||||
- **Tradeoffs:** + tiny diff, preserves muscle memory, fast. − horizontal scroll is less
|
||||
scannable than vertical; limited metadata per chip; long lists hide off-screen.
|
||||
|
||||
### Concept B — "Activity History panel" (collapsed timeline bar → expanding vertical list) — RECOMMENDED
|
||||
- **Thesis:** the dock's **collapsed** state is a slim **status-segment bar** — one thin
|
||||
segment per run, colored by status, newest on the right, labeled "N runs · last ✓ 2m ago".
|
||||
That's the at-a-glance, time-ordered "timeline." Click/hover **expands upward** into a
|
||||
**vertical, scannable list**: one rich row per run = status glyph · block icon · block name ·
|
||||
type pill · mode pill (Agent/Code) · duration · "x ago", with a one-line failure reason on
|
||||
failures. Newest first. Row click → navigate (existing behavior); current run highlighted.
|
||||
- **Lineage:** Vercel Activity feed (legible rows) + Okta count-over-time bar (collapsed
|
||||
status density) + WorkOS master-detail (list feeds the existing right-side detail rail).
|
||||
- **DS:** `Popover`/`Collapsible` + `ScrollArea`, the `WorkflowRunTimelineBlockItem` row
|
||||
vocabulary, `StatusDot`, pills. New: a small **status-segment bar** (gap-ledger item).
|
||||
- **Tradeoffs:** + most legible (vertical rows, full metadata — directly satisfies the AC),
|
||||
+ collapsed footprint stays tiny so the canvas isn't crowded, + serves *both* the
|
||||
"scannable history" and "proper timeline" instincts, + scales to long lists.
|
||||
− one extra primitive (segment bar); − popover sizing over the canvas needs care.
|
||||
|
||||
### Concept C — "Run Selector + Segmented Timeline" (header selector + bottom bar)
|
||||
- **Thesis:** Databricks-style. A **run selector** near the header ("Run: Login ✓ · 2m ago ▾")
|
||||
opens the activity list, *and* a persistent thin **segmented timeline bar** sits at the
|
||||
bottom (each run a proportional, status-colored segment; hover→tooltip, click→navigate).
|
||||
- **Lineage:** Databricks run-selector + Okta status-density bar.
|
||||
- **DS:** `Popover`, segment bar (new), `StatusDot` colors.
|
||||
- **Tradeoffs:** + strongest "timeline" metaphor, scales well. − most net-new UI and two
|
||||
surfaces (header + bottom) risk fragmenting the history; − largest build/QA; − header is
|
||||
already busy in build mode.
|
||||
|
||||
## 6. Recommendation & Decision
|
||||
|
||||
**Recommend Concept B.** It's the most faithful to the acceptance criteria — each entry
|
||||
legible at a glance with real metadata, and one click to jump to a run — while keeping the
|
||||
collapsed footprint as small as today's dot pill. Its collapsed status-segment bar answers the
|
||||
requester's "proper timeline?" instinct without splitting the history across two surfaces
|
||||
(Concept C's main risk), and it reuses the established run-row vocabulary so it looks native.
|
||||
|
||||
Decision (2026-06-15): requester chose to **build all three (A, B, C) behind a live variant
|
||||
toggle** so they can compare directly in the PR preview, then collapse to one winner later.
|
||||
Implementation ships a `RecentActivity` orchestrator that renders the variant selected by a
|
||||
persisted (localStorage + `?activityVariant=` URL seed) toggle; default = B.
|
||||
|
||||
## 7. Implementation (shipped)
|
||||
|
||||
New module `skyvern-frontend/src/routes/workflows/debugger/recentActivity/`:
|
||||
- `useRecentActivity.ts` — data hook (debug-session runs, sorted asc; block-type map;
|
||||
`navigateToRun` preserving the old select→navigate target, now also resolving nested loop
|
||||
blocks for the "block still exists" guard).
|
||||
- `runActivity.ts` / `RunGlyphs.tsx` — shared status/duration/time helpers + status/block glyphs.
|
||||
- `RecentActivitySegmentBar.tsx`, `RecentActivityList.tsx` — shared primitives.
|
||||
- `RecentActivityStrip.tsx` (A), `RecentActivityPanel.tsx` (B), `RecentActivityTimeline.tsx`
|
||||
(C), `RecentActivityVariantToggle.tsx`, `RecentActivity.tsx` (orchestrator).
|
||||
- `src/store/RecentActivityVariantStore.ts` — variant store.
|
||||
- `Workspace.tsx` now renders `<RecentActivity />`; `DebuggerBlockRuns.tsx` deleted.
|
||||
|
||||
Verification: `tsc --noEmit` ✓, `eslint --max-warnings 0` ✓, `vite build` ✓. Live visual
|
||||
verification of the populated state needs a debug session with ≥1 run (backend + browser
|
||||
session) — delegated to the PR preview by design (the whole point of the A/B/C toggle).
|
||||
|
||||
### Gap ledger outcome (→ Step 5 DS ticket)
|
||||
- Shipped one-off **`RecentActivitySegmentBar`** (status-segment/timeline-bar primitive) — DS has none.
|
||||
- Re-derived **run-status → icon/color** inline (`RunGlyphs` + `STATUS_PILL_TONE`/`SEGMENT_FILL`)
|
||||
because there's no shared `RunStatusBadge`.
|
||||
- Filed **SKY-11082** (team Skyvern AI) to extract both primitives into the DS once the winning
|
||||
variant is chosen. Pure helpers covered by `runActivity.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
# Round 2 — Placement exploration (2026-06-16)
|
||||
|
||||
The A/B/C work settled the *surface design*; this round explores *where on the build page*
|
||||
the surface lives. Orthogonal axis: any style (A/B/C) can sit at any placement.
|
||||
|
||||
## Frame (placement)
|
||||
|
||||
The surface only renders in **debug/browser mode** (`showBrowser`), inside the vertical
|
||||
`Splitter`. Candidate regions and their occupants:
|
||||
- **Left pane** = code (left half) + infinite canvas (right half). Bottom-left corner holds the
|
||||
FlowRenderer controls (FitView/Lock/GlobalCollapse); top is overlaid by `WorkflowHeader`
|
||||
(`absolute left-6 top-8 h-20`) and header-anchored sub-panels (`top-[8.5rem]`).
|
||||
- **Right pane** (`skyvern-split-right`) = live browser + footer (copilot/power/reload); far
|
||||
right edge holds the run-detail **timeline rail** (`w-[5rem]`, expandable, `z-[15]`).
|
||||
|
||||
**Deciding constraint:** a placement must not block the live browser, the canvas controls, or
|
||||
the run-detail timeline, and should not fight the header/sub-panels. So placements are
|
||||
pointer-events-transparent floating bands (only the dock itself captures clicks).
|
||||
|
||||
## Inspiration (Mobbin — web, node-canvas editors)
|
||||
- **WRITER — Blueprints**: execution log as a full-width **bottom** panel (node rows + status +
|
||||
Trace + duration). [link](https://mobbin.com/screens/2368f17d-4fcc-4590-912d-08cb39245dab)
|
||||
- **n8n — Executions**: run history as a **left side panel** (status + duration, auto-refresh),
|
||||
entered via a top **Editor | Executions** tab.
|
||||
[link](https://mobbin.com/screens/d3af4413-04f9-4534-8aa4-01916d2b4910)
|
||||
- **StackAI — Version history / Run Details**: **right side panel** co-located with the run
|
||||
(status, timestamps, duration, tokens).
|
||||
[link](https://mobbin.com/screens/cffa32bb-fa1e-43fa-913b-27001b35f395) ·
|
||||
[link](https://mobbin.com/screens/bb0174f4-60aa-4e30-ac5f-73679b160f38)
|
||||
- **Databricks** (round 1): top **run-selector** + bottom **event log** in one editor.
|
||||
|
||||
## Placement concepts
|
||||
|
||||
- **P1 — Bottom dock (baseline, current):** floating bottom-center over the canvas/code pane.
|
||||
Lineage: WRITER bottom log + StackAI bottom toolbar. + canvas stays clear, controls reachable.
|
||||
− easy to miss; far from where the eye starts.
|
||||
- **P2 — Top rail (under header):** pinned top-center over the canvas/code pane, just below the
|
||||
header. Lineage: n8n Editor|Executions top tab + Databricks top run-selector. + high
|
||||
discoverability, reads top-down, near run controls. − competes with header sub-panels when one
|
||||
is open; popover opens downward over the canvas.
|
||||
- **P3 — Browser-pane header (co-located with run viewing):** pinned to the top of the right
|
||||
(live-browser) pane, so picking a recent run sits beside viewing that run + its detail
|
||||
timeline. Lineage: StackAI right-side run history/details. + strongest pick→view coherence
|
||||
(directly answers the ticket's "reconcile with run viewing"). − eats a strip of browser space;
|
||||
must clear the far-right timeline rail.
|
||||
|
||||
**Recommendation:** **P3 (browser-pane header)** for coherence — it puts "which run" right next
|
||||
to "this run's browser + timeline," which is the actual debugging loop — with **P1** as the
|
||||
low-risk default if we want the canvas untouched. P2 is the most discoverable but the most
|
||||
crowded.
|
||||
|
||||
**Decision (2026-06-16):** per the established pattern, build all three behind a **placement
|
||||
toggle** (orthogonal to the A/B/C style toggle; persisted via localStorage + `?activityPlacement=`
|
||||
URL seed; default `bottom`) so they can be compared live in the PR preview.
|
||||
|
||||
### Gap-ledger addition
|
||||
- No shared **floating-dock anchor** convention for build-canvas overlays (each overlay
|
||||
re-implements `absolute … pointer-events-none [&>*]:pointer-events-auto`). Minor; noted on
|
||||
[[SKY-11082]] rather than a new ticket.
|
||||
|
||||
## Round 2 decision — style converges to one "Activity Timeline" (2026-06-16)
|
||||
|
||||
Requester: "I like C the most, and B for its compression and all the events in one place."
|
||||
→ **Merge B + C into a single default surface** and drop A (and the A/B/C style toggle):
|
||||
|
||||
**Activity Timeline** — compact two-row dock:
|
||||
- Row 1: a **run selector** ("Run: ✓ Login · 2m ago ▾") + a compression summary ("N runs ·
|
||||
last ✓ 2m ago"). The selector opens **B's consolidated, scannable event list**
|
||||
(`RecentActivityList`) — all events in one place.
|
||||
- Row 2: a **persistent segmented timeline bar** (C) — proportional, status-colored; hover→tip,
|
||||
click a segment → jump to that run.
|
||||
|
||||
This keeps C's timeline metaphor + direct segment navigation, with B's compression (compact
|
||||
collapsed form) and single consolidated list. Built behind the **placement toggle** (bottom /
|
||||
top / right) so location can still be compared in the PR preview.
|
||||
|
||||
## Round 3 decision — placement locked to browser-pane header (2026-06-16)
|
||||
|
||||
Requester locked the **browser-pane header (right)** placement. Removed the placement toggle +
|
||||
store and the bottom/top anchors; `RecentActivity` now mounts unconditionally at the top of the
|
||||
live-browser pane (`popoverSide="bottom"`). Rendered visual proof (mock runs) below — produced
|
||||
via a throwaway standalone preview page, not committed.
|
||||
|
||||

|
||||
|
||||
## Round 4 — re-added the 3-way placement toggle (2026-06-16)
|
||||
|
||||
Reverted the round-3 lock: requester wants to compare bottom / top / right **by eye** in the
|
||||
preview rather than commit blind. Restored `RecentActivityPlacementStore` +
|
||||
`RecentActivityPlacementToggle` and the three Workspace anchors (default `bottom`,
|
||||
`?activityPlacement=` URL seed). Decide, then re-lock.
|
||||
|
||||

|
||||
|
||||
## Round 5 — drop floating, integrate the run selector (2026-06-16)
|
||||
|
||||
Real screenshots showed every floating placement collides with canvas/browser content and
|
||||
duplicates the existing right-side run-detail panel (`DebuggerRunTimeline`: Actions/Steps/Credits
|
||||
+ block/action timeline) — the overlap the ticket warned about. So: **remove the floating dock,
|
||||
segmented bar, and placement toggle**, and make recent-activity a compact **run selector** that
|
||||
opens the consolidated list. Two integration points behind a small mode toggle (default `panel`,
|
||||
`?activityMode=panel|toolbar`):
|
||||
- **panel:** selector at the top of the right run-detail panel (`DebuggerRun`) — co-located with
|
||||
viewing; picking a run drives the panel below.
|
||||
- **toolbar:** selector as a dropdown in the editor header toolbar (`EditorActionToolbar`).
|
||||
Removed: `RecentActivity`, `RecentActivityTimeline`, `RecentActivitySegmentBar`,
|
||||
`RecentActivityPlacementToggle`, `RecentActivityPlacementStore`. Added: `RecentActivityRunSelector`,
|
||||
`RecentActivityIntegrationStore`, `RecentActivityIntegrationToggle`.
|
||||
|
||||
## Round 6 — timeline strip below the browser (2026-06-16)
|
||||
|
||||
Panel/toolbar selectors still read as bolted-on. Requester idea: dock the timeline as a
|
||||
horizontal strip **below the live browser session window** — the browser is what you're
|
||||
watching, so a run history beneath it is contextual (WRITER bottom log / Databricks event-log
|
||||
lineage). Added as a third integration option (`browser`) on the mode toggle:
|
||||
`RecentActivityStrip` — a scannable horizontal row of run chips (oldest→newest, auto-scrolled to
|
||||
newest, click→jump) + an "N runs ▾" button opening the consolidated list. Mounted between the
|
||||
browser stream and its footer in both VNC and CDP panels. Compare via the Panel/Toolbar/Browser
|
||||
toggle (`?activityMode=panel|toolbar|browser`).
|
||||
|
||||
## Round 7 — placement locked to the run-detail panel; preview toggle removed (final, 2026-06-29)
|
||||
|
||||
Compared the three integrations live in the PR preview and **locked `panel`**: the run
|
||||
selector mounts at the top of the right run-detail panel (`DebuggerRun`), co-located with
|
||||
viewing a run, which reconciles the history surface with run viewing (the ticket's open
|
||||
question) without a second overlapping surface. Removed the exploration scaffolding —
|
||||
`RecentActivityIntegrationStore` (+test), `RecentActivityIntegrationToggle`, and
|
||||
`RecentActivityStrip` — plus the toolbar/browser wirings in `WorkflowHeader`/`Workspace`.
|
||||
`RecentActivityRunSelector` mounts unconditionally (it self-hides until the debug session has
|
||||
≥1 run) and its now-single-use `variant`/`popoverSide` props were dropped. Run rows in the
|
||||
consolidated list are disabled while a workflow run is in progress (navigation is a no-op
|
||||
then), covered by `RecentActivityList.test.tsx`. Dead exports `SEGMENT_FILL` and
|
||||
`getRunTooltip` (orphaned when the segment bar / strip were cut) were removed.
|
||||
BIN
docs/design/activity-timeline-placements.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
docs/design/activity-timeline-preview.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
|
|
@ -26,7 +26,7 @@ for (const c of creds) {
|
|||
|-----------|------|----------|---------|-------------|
|
||||
| `page` | `int` | No | `None` | Page number. |
|
||||
| `page_size` | `int` | No | `None` | Results per page. |
|
||||
| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"custom"`, `"bitwarden"`, `"azure_vault"`). |
|
||||
| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"skyvern"`, `"custom"`, `"bitwarden"`, `"azure_vault"`). |
|
||||
| `request_options` | `RequestOptions` | No | `None` | Per-request configuration (see below). |
|
||||
|
||||
### Returns `list[CredentialResponse]`
|
||||
|
|
|
|||
|
|
@ -64,6 +64,16 @@ codex mcp get skyvern
|
|||
|
||||
If you previously configured Skyvern with an `x-api-key` header, run `codex mcp remove skyvern` first so Codex does not skip the OAuth handshake. You do not need to run `codex mcp login skyvern` after `add` — the OAuth flow runs inline. Use `codex mcp login skyvern` only to re-authenticate after a token expires or to request custom `--scopes`.
|
||||
|
||||
<Note>
|
||||
If `codex mcp add` fails with `resource is not allowed for this authorization server`, your Codex version derived a trailing-slash `resource` from the URL. Pass the slashless resource explicitly:
|
||||
|
||||
```bash
|
||||
codex mcp add skyvern --url https://api.skyvern.com/mcp/ --oauth-resource https://api.skyvern.com/mcp
|
||||
```
|
||||
|
||||
Skyvern Cloud accepts both forms, so the flag is only needed on older self-hosted deployments.
|
||||
</Note>
|
||||
|
||||
</Tab>
|
||||
<Tab title="Cursor">
|
||||
|
||||
|
|
@ -130,18 +140,19 @@ claude mcp add-json skyvern '{"type":"http","url":"https://api.skyvern.com/mcp/"
|
|||
3. When Claude Desktop opens the Skyvern installer preview, click **Install**.
|
||||
4. In the **Configure Skyvern** dialog, paste your Skyvern API key.
|
||||
5. Leave **Skyvern MCP URL** set to `https://api.skyvern.com/mcp/` unless you are connecting to a different deployment, then click **Save**.
|
||||
6. In Claude, ask: "Create a Skyvern browser session". A working setup returns a Skyvern browser session id.
|
||||
|
||||
If double-clicking is blocked by your system, you can instead open **Claude Desktop > Settings > Extensions > Install Extension** and select the downloaded file manually.
|
||||
|
||||
The bundle defaults to `https://api.skyvern.com/mcp/` and removes the separate Node.js requirement.
|
||||
|
||||
**Linux, self-hosted, or advanced fallback (requires Node.js >= 20)**
|
||||
<Note>
|
||||
If Claude says Skyvern "needs authorization" or mentions OAuth, the API key is not configured. Open Claude Desktop **Settings -> Extensions -> Skyvern -> Configure**, paste your Skyvern API key, and save.
|
||||
</Note>
|
||||
|
||||
Add this manual bridge to your Claude Desktop config file:
|
||||
**Linux manual config (requires Node.js >= 20)**
|
||||
|
||||
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
|
||||
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
On Linux, add this manual bridge to `~/.config/Claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -159,7 +170,7 @@ Add this manual bridge to your Claude Desktop config file:
|
|||
}
|
||||
```
|
||||
|
||||
Use this fallback for Linux or any Claude Desktop setup where the one-click bundle is not available. It uses [mcp-remote](https://www.npmjs.com/package/mcp-remote) to bridge the remote MCP server to stdio and requires Node.js >= 20.
|
||||
Use this Linux path when the one-click bundle is not available on your platform. It uses [mcp-remote](https://www.npmjs.com/package/mcp-remote) to bridge the remote MCP server to stdio and requires Node.js >= 20.
|
||||
|
||||
</Tab>
|
||||
<Tab title="Cursor">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ mode: custom
|
|||
<div className="cl-page">
|
||||
<div className="cl-sidebar">
|
||||
<p className="cl-sidebar-title">TIMELINE</p>
|
||||
<a href="#week-of-june-30%2C-2026" className="cl-sidebar-link">Week of Jun 30, 2026</a>
|
||||
<a href="#week-of-june-22%2C-2026" className="cl-sidebar-link">Week of Jun 22, 2026</a>
|
||||
<a href="#week-of-june-15%2C-2026" className="cl-sidebar-link">Week of Jun 15, 2026</a>
|
||||
<a href="#week-of-june-8%2C-2026" className="cl-sidebar-link">Week of Jun 8, 2026</a>
|
||||
|
|
@ -19,18 +20,36 @@ mode: custom
|
|||
<a href="#week-of-april-21%2C-2026" className="cl-sidebar-link">Week of Apr 21, 2026</a>
|
||||
<a href="#week-of-april-14%2C-2026" className="cl-sidebar-link">Week of Apr 14, 2026</a>
|
||||
<a href="#week-of-march-30%2C-2026" className="cl-sidebar-link">Week of Mar 30, 2026</a>
|
||||
<a href="#v1-0-22-—-february-26%2C-2026" className="cl-sidebar-link">v1.0.22 — Feb 26, 2026</a>
|
||||
<a href="#v1-0-21-—-february-24%2C-2026" className="cl-sidebar-link">v1.0.21 — Feb 24, 2026</a>
|
||||
<a href="#v1-0-18-—-february-19%2C-2026" className="cl-sidebar-link">v1.0.18 — Feb 19, 2026</a>
|
||||
<a href="#v1-0-17-—-february-19%2C-2026" className="cl-sidebar-link">v1.0.17 — Feb 19, 2026</a>
|
||||
<a href="#v1-0-16-—-february-19%2C-2026" className="cl-sidebar-link">v1.0.16 — Feb 19, 2026</a>
|
||||
<a href="#v1-0-15-—-february-17%2C-2026" className="cl-sidebar-link">v1.0.15 — Feb 17, 2026</a>
|
||||
<a href="#week-of-february-23%2C-2026" className="cl-sidebar-link">Week of Feb 23, 2026</a>
|
||||
<a href="#week-of-february-16%2C-2026" className="cl-sidebar-link">Week of Feb 16, 2026</a>
|
||||
</div>
|
||||
|
||||
<div className="cl-content">
|
||||
<h1 className="cl-title">Changelog</h1>
|
||||
<p className="cl-subtitle">New features, improvements, and fixes in Skyvern</p>
|
||||
|
||||
<Update label="Week of June 30, 2026" description="Workflow Studio UX improvements, faster enrichment, code-block self-heal, extraction accuracy, bug fixes">
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Block Type Shown in Workflow Studio Header** — The block detail panel in Workflow Studio now displays the block type in the header, making it easier to identify what kind of block you're editing at a glance. ([#6956](https://github.com/Skyvern-AI/skyvern/pull/6956))
|
||||
- **Improved Extraction Accuracy for Absent Data** — The agent now better handles cases where requested output is genuinely absent from the page, reducing false positives in extraction results. ([#6957](https://github.com/Skyvern-AI/skyvern/pull/6957))
|
||||
- **Data-Only Validation Skips Page Evidence** — Validation steps that check only extracted data no longer capture page screenshots when they aren't needed, reducing unnecessary work during runs. ([#6960](https://github.com/Skyvern-AI/skyvern/pull/6960))
|
||||
- **Copilot Block Runs Routed to UI Worker** — Copilot block runs are now dispatched to a dedicated UI worker, keeping the editor responsive during live automation. ([#6962](https://github.com/Skyvern-AI/skyvern/pull/6962))
|
||||
- **Clearer Invalid JSON Errors in Workflow Editor** — The workflow editor now surfaces a descriptive error when a parameter contains invalid JSON, making it faster to diagnose and fix configuration mistakes. ([#6963](https://github.com/Skyvern-AI/skyvern/pull/6963))
|
||||
- **"Ignore System Prompt" Moved to Advanced Settings** — The "Ignore system prompt" option has been relocated to the advanced settings panel, reducing clutter in the default block configuration view. ([#6964](https://github.com/Skyvern-AI/skyvern/pull/6964))
|
||||
- **Empty Extra HTTP Headers Hidden** — Extra HTTP header fields that are empty no longer appear in block configurations, reducing visual noise in the workflow editor. ([#6966](https://github.com/Skyvern-AI/skyvern/pull/6966))
|
||||
- **Block Selection Persisted in URL** — The selected block in Workflow Studio is now encoded in the URL, so your selection is preserved on reload and can be shared via link. ([#6969](https://github.com/Skyvern-AI/skyvern/pull/6969))
|
||||
- **Code-Block Self-Heal Minimum Retry Floor** — The code-block self-healing loop now enforces a minimum number of repair attempts before giving up, preventing premature failures on recoverable errors. ([#6971](https://github.com/Skyvern-AI/skyvern/pull/6971))
|
||||
- **Faster Live-Draft Enrichment** — Workflow recording live-draft enrichment now uses a faster model and a shorter debounce interval, making draft suggestions appear noticeably sooner while recording. ([#6973](https://github.com/Skyvern-AI/skyvern/pull/6973))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed syntax highlighting disappearing when viewing large webhook payloads. ([#6967](https://github.com/Skyvern-AI/skyvern/pull/6967))
|
||||
- Fixed LLM blocks not enforcing their configured output schema, which could allow malformed data to propagate to downstream blocks. ([#6965](https://github.com/Skyvern-AI/skyvern/pull/6965))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Week of June 22, 2026" description="Workflow Studio preview, new models, label management, Gmail OTP, Copilot improvements, analytics filters, bug fixes">
|
||||
|
||||
## New features
|
||||
|
|
@ -473,37 +492,34 @@ mode: custom
|
|||
|
||||
</Update>
|
||||
|
||||
<Update label="v1.0.22 — February 26, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, and more">
|
||||
<Update label="Week of February 23, 2026" description="Adaptive caching, Workflow Trigger block, CLI signup, reserved parameters, new LLM models">
|
||||
|
||||
## New features
|
||||
|
||||
- **Adaptive Caching (Script Generation)** — Skyvern can now learn from repeated workflow runs and generate cached scripts that speed up future executions. When a block runs successfully, Skyvern records a reusable script and replays it on subsequent runs, falling back to the AI agent only if the script fails. ([#4908](https://github.com/Skyvern-AI/skyvern/pull/4908), [#4916](https://github.com/Skyvern-AI/skyvern/pull/4916), [#4917](https://github.com/Skyvern-AI/skyvern/pull/4917), [#4920](https://github.com/Skyvern-AI/skyvern/pull/4920), [#4922](https://github.com/Skyvern-AI/skyvern/pull/4922), [#4931](https://github.com/Skyvern-AI/skyvern/pull/4931))
|
||||
|
||||
- **Workflow Trigger Block** — A new block type that lets one workflow trigger another from within the workflow editor. Chain workflows together as building blocks for complex automations. ([#4885](https://github.com/Skyvern-AI/skyvern/pull/4885))
|
||||
|
||||
- **Browser-based CLI Signup** — New users can sign up for Skyvern Cloud directly from the CLI. Running `skyvern signup` opens a browser flow that creates your account and stores your API key locally. ([#4925](https://github.com/Skyvern-AI/skyvern/pull/4925))
|
||||
|
||||
- **Interactive ngrok Tunnel** — `skyvern browser serve` can now create an ngrok tunnel automatically, making it easy to expose your local browser to Skyvern Cloud without manual network configuration. ([#4924](https://github.com/Skyvern-AI/skyvern/pull/4924))
|
||||
|
||||
- **South Korea Proxy Location** — Added `RESIDENTIAL_KR` as a proxy geolocation option for automations targeting South Korean websites. ([#4918](https://github.com/Skyvern-AI/skyvern/pull/4918))
|
||||
|
||||
- **Downloaded Files Tab** — Browser session detail pages now include a Downloaded Files tab for viewing and accessing files captured during a session. ([#4911](https://github.com/Skyvern-AI/skyvern/pull/4911))
|
||||
|
||||
- **CDP Proxy Authentication** — Remote browser connections now support authenticated proxies via the CDP `Fetch.authRequired` protocol. ([#4936](https://github.com/Skyvern-AI/skyvern/pull/4936))
|
||||
|
||||
- **Remote Browser Download Interception** — File downloads triggered by XHR requests and other non-navigation events are now captured when using remote CDP browser connections. ([#4906](https://github.com/Skyvern-AI/skyvern/pull/4906), [#4921](https://github.com/Skyvern-AI/skyvern/pull/4921), [#4934](https://github.com/Skyvern-AI/skyvern/pull/4934))
|
||||
- **`current_date` Reserved Parameter** — Workflows now have a built-in `current_date` parameter that resolves to today's date automatically. ([#4854](https://github.com/Skyvern-AI/skyvern/pull/4854))
|
||||
- **Reserved Parameters in Block Editor** — The workflow block parameter picker now shows reserved system parameters, making them discoverable without memorizing names. ([#4857](https://github.com/Skyvern-AI/skyvern/pull/4857))
|
||||
- **Natural Language Loop `data_schema`** — Loop blocks driven by natural language extraction now support a `data_schema` field for consistent output structure. ([#4851](https://github.com/Skyvern-AI/skyvern/pull/4851))
|
||||
- **Gemini 3.1 Pro and Inception Mercury-2** — Two new LLM engine options for your automations. ([#4847](https://github.com/Skyvern-AI/skyvern/pull/4847))
|
||||
- **MCP Server on API Service** — The MCP remote server is now mounted at `/mcp` on the existing API, eliminating the need for a separate process. ([#4843](https://github.com/Skyvern-AI/skyvern/pull/4843))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Renamed "Login-Free" to "Saved Profile"** — Clearer terminology throughout the UI. Browser session checkbox wording has also been improved. ([#4914](https://github.com/Skyvern-AI/skyvern/pull/4914))
|
||||
|
||||
- **TOTP Verification Improvements** — Better timer handling, expired code retry, 2FA banner suppression, and more reliable goal-text extraction during TOTP flows. ([#4860](https://github.com/Skyvern-AI/skyvern/pull/4860))
|
||||
|
||||
- **TOTP Webhook includes `workflow_permanent_id`** — Easier to identify which workflow is requesting a 2FA code. ([#4871](https://github.com/Skyvern-AI/skyvern/pull/4871))
|
||||
|
||||
- **Consolidated Gemini 3 Pro Model Key** — `VERTEX_GEMINI_3_PRO` now auto-resolves to the latest version, so you no longer need to update config when Google releases point versions. ([#4926](https://github.com/Skyvern-AI/skyvern/pull/4926))
|
||||
|
||||
- **Browser Rotation for Remote Connections** — Remote browser environments now support context rotation, improving reliability for long-running automations. ([#4929](https://github.com/Skyvern-AI/skyvern/pull/4929))
|
||||
- Workflow save validation (HTTP 422) now returns clear, field-level error messages. ([#4852](https://github.com/Skyvern-AI/skyvern/pull/4852))
|
||||
- Browser launch errors now show actionable messages instead of raw exceptions. ([#4844](https://github.com/Skyvern-AI/skyvern/pull/4844))
|
||||
- Cloud LLM fallback overrides are now properly applied when selecting models. ([#4839](https://github.com/Skyvern-AI/skyvern/pull/4839))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
|
|
@ -515,67 +531,22 @@ mode: custom
|
|||
- MCP remote auth token comparison no longer fails on encrypted tokens. ([#4863](https://github.com/Skyvern-AI/skyvern/pull/4863))
|
||||
- `skyvern quickstart` now handles local PostgreSQL without a pre-existing `skyvern` role. ([#4878](https://github.com/Skyvern-AI/skyvern/pull/4878))
|
||||
- WebSocket URLs through ngrok tunnels now rewrite correctly for live browser streaming. ([#4927](https://github.com/Skyvern-AI/skyvern/pull/4927))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v1.0.21 — February 24, 2026" description="Reserved parameters, data_schema for loops, new LLM models">
|
||||
|
||||
## New features
|
||||
|
||||
- **`current_date` Reserved Parameter** — Workflows now have a built-in `current_date` parameter that resolves to today's date automatically. ([#4854](https://github.com/Skyvern-AI/skyvern/pull/4854))
|
||||
|
||||
- **Reserved Parameters in Block Editor** — The workflow block parameter picker now shows reserved system parameters, making them discoverable without memorizing names. ([#4857](https://github.com/Skyvern-AI/skyvern/pull/4857))
|
||||
|
||||
- **Natural Language Loop `data_schema`** — Loop blocks driven by natural language extraction now support a `data_schema` field for consistent output structure. ([#4851](https://github.com/Skyvern-AI/skyvern/pull/4851))
|
||||
|
||||
- **Gemini 3.1 Pro and Inception Mercury-2** — Two new LLM engine options for your automations. ([#4847](https://github.com/Skyvern-AI/skyvern/pull/4847))
|
||||
|
||||
- **MCP Server on API Service** — The MCP remote server is now mounted at `/mcp` on the existing API, eliminating the need for a separate process. ([#4843](https://github.com/Skyvern-AI/skyvern/pull/4843))
|
||||
|
||||
## Improvements
|
||||
|
||||
- Workflow save validation (HTTP 422) now returns clear, field-level error messages. ([#4852](https://github.com/Skyvern-AI/skyvern/pull/4852))
|
||||
- Browser launch errors now show actionable messages instead of raw exceptions. ([#4844](https://github.com/Skyvern-AI/skyvern/pull/4844))
|
||||
- Cloud LLM fallback overrides are now properly applied when selecting models. ([#4839](https://github.com/Skyvern-AI/skyvern/pull/4839))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Conditional blocks no longer crash when all branches use Jinja templates. ([#4849](https://github.com/Skyvern-AI/skyvern/pull/4849))
|
||||
- Conditional blocks no longer misinterpret resolved Jinja template variables. ([#4841](https://github.com/Skyvern-AI/skyvern/pull/4841))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v1.0.18 — February 19, 2026" description="Browser profile testing and saved-profile workflows">
|
||||
<Update label="Week of February 16, 2026" description="Browser profile testing, Skills package, 2FA detection, full CLI parity, Claude Opus 4.6">
|
||||
|
||||
## New features
|
||||
|
||||
- **Browser Profile Testing** — Test browser profiles directly from the UI to verify that saved login sessions are still valid. Workflows can also run with a saved browser profile, enabling login-free automations that reuse authenticated sessions. ([#4818](https://github.com/Skyvern-AI/skyvern/pull/4818))
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- When both a TOTP credential and a webhook are configured for 2FA, the credential is now correctly prioritized. ([#4811](https://github.com/Skyvern-AI/skyvern/pull/4811))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v1.0.17 — February 19, 2026" description="Skills package and cached script management">
|
||||
|
||||
## New features
|
||||
|
||||
- **Skyvern Skills Package** — The new `skyvern skill` CLI command provides pre-built workflow templates and reference documentation for common automation patterns. ([#4817](https://github.com/Skyvern-AI/skyvern/pull/4817))
|
||||
|
||||
- **Clear Cached Scripts API** — A new endpoint lets you clear cached automation scripts for a workflow when a target website changes and you want to force Skyvern to re-learn. ([#4809](https://github.com/Skyvern-AI/skyvern/pull/4809))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v1.0.16 — February 19, 2026" description="2FA detection, full CLI parity, Claude Opus 4.6 CUA">
|
||||
|
||||
## New features
|
||||
|
||||
- **Automatic 2FA Detection Without TOTP Credentials** — Skyvern now detects when a website asks for a 2FA code even without pre-configured TOTP credentials. The system pauses and waits for you to provide the code via the UI or webhook. ([#4786](https://github.com/Skyvern-AI/skyvern/pull/4786))
|
||||
|
||||
- **Full CLI Parity with MCP** — The CLI now supports browser session management, credential management, block operations, and enhanced workflow commands. Everything available through MCP is now accessible from the command line. ([#4789](https://github.com/Skyvern-AI/skyvern/pull/4789), [#4792](https://github.com/Skyvern-AI/skyvern/pull/4792), [#4793](https://github.com/Skyvern-AI/skyvern/pull/4793))
|
||||
|
||||
- **Claude Opus 4.6 CUA Support** — Anthropic's Claude Opus 4.6 is now available as a Computer Use Agent model for driving browser interactions. ([#4780](https://github.com/Skyvern-AI/skyvern/pull/4780))
|
||||
- **Anthropic Claude Opus 4.6** — Added Claude Opus 4.6 as an available LLM engine for web automation tasks. ([#4777](https://github.com/Skyvern-AI/skyvern/pull/4777), [#4778](https://github.com/Skyvern-AI/skyvern/pull/4778))
|
||||
|
||||
## Improvements
|
||||
|
||||
|
|
@ -583,17 +554,10 @@ mode: custom
|
|||
|
||||
## Bug fixes
|
||||
|
||||
- When both a TOTP credential and a webhook are configured for 2FA, the credential is now correctly prioritized. ([#4811](https://github.com/Skyvern-AI/skyvern/pull/4811))
|
||||
- Fixed conditional blocks evaluating the wrong value after Jinja template rendering. ([#4801](https://github.com/Skyvern-AI/skyvern/pull/4801))
|
||||
- Fixed MFA resolution priority when both TOTP credentials and webhooks are configured. ([#4800](https://github.com/Skyvern-AI/skyvern/pull/4800))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v1.0.15 — February 17, 2026" description="Claude Opus 4.6 model support">
|
||||
|
||||
## New features
|
||||
|
||||
- **Anthropic Claude Opus 4.6** — Added Claude Opus 4.6 as an available LLM engine for web automation tasks. ([#4777](https://github.com/Skyvern-AI/skyvern/pull/4777), [#4778](https://github.com/Skyvern-AI/skyvern/pull/4778))
|
||||
|
||||
</Update>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3343,10 +3343,10 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')",
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')",
|
||||
"title": "Vault Type"
|
||||
},
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')"
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')"
|
||||
},
|
||||
{
|
||||
"name": "credential_type",
|
||||
|
|
@ -8301,6 +8301,62 @@
|
|||
],
|
||||
"title": "Source Browser Type"
|
||||
},
|
||||
"proxy_location": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProxyLocation"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GeoTarget"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Location"
|
||||
},
|
||||
"proxy_session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Session Id"
|
||||
},
|
||||
"is_managed": {
|
||||
"type": "boolean",
|
||||
"title": "Is Managed",
|
||||
"default": false
|
||||
},
|
||||
"workflow_permanent_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Workflow Permanent Id"
|
||||
},
|
||||
"browser_profile_key_digest": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Browser Profile Key Digest"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
|
|
@ -9255,6 +9311,37 @@
|
|||
],
|
||||
"title": "Workflow Run Id",
|
||||
"description": "Workflow run whose persisted session should be captured. Omit for a blank profile."
|
||||
},
|
||||
"proxy_location": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProxyLocation"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GeoTarget"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Location",
|
||||
"description": "Optional proxy location for this profile's pinned proxy identity."
|
||||
},
|
||||
"proxy_session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Session Id",
|
||||
"description": "Explicit sticky-session id for this profile's pinned proxy identity."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -9394,10 +9481,48 @@
|
|||
],
|
||||
"description": "Which vault to store this credential in. If omitted, uses the instance default. Use this to mix Skyvern-hosted and custom credentials within the same organization.",
|
||||
"examples": [
|
||||
"skyvern",
|
||||
"custom",
|
||||
"azure_vault",
|
||||
"bitwarden"
|
||||
]
|
||||
},
|
||||
"proxy_location": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProxyLocation"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GeoTarget"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Location",
|
||||
"description": "Optional proxy location for this credential's pinned proxy identity."
|
||||
},
|
||||
"proxy_session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Session Id",
|
||||
"description": "Optional advanced reuse key for this credential's pinned proxy identity."
|
||||
},
|
||||
"rotate_proxy_session_id": {
|
||||
"type": "boolean",
|
||||
"title": "Rotate Proxy Session Id",
|
||||
"description": "Rotate the Skyvern-managed proxy sticky-session id when updating this credential.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -9678,7 +9803,7 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')"
|
||||
"description": "Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')"
|
||||
},
|
||||
"browser_profile_id": {
|
||||
"anyOf": [
|
||||
|
|
@ -9767,6 +9892,7 @@
|
|||
"CredentialVaultType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"skyvern",
|
||||
"bitwarden",
|
||||
"azure_vault",
|
||||
"gcp",
|
||||
|
|
@ -18656,6 +18782,16 @@
|
|||
"type": "boolean",
|
||||
"title": "Script Run",
|
||||
"default": false
|
||||
},
|
||||
"trigger_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/WorkflowRunTriggerType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -20339,6 +20475,43 @@
|
|||
],
|
||||
"title": "Description",
|
||||
"description": "New description for the browser profile"
|
||||
},
|
||||
"proxy_location": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProxyLocation"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GeoTarget"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Location",
|
||||
"description": "Optional proxy location for this profile's pinned proxy identity."
|
||||
},
|
||||
"proxy_session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Proxy Session Id",
|
||||
"description": "Opaque Skyvern-managed proxy sticky-session id."
|
||||
},
|
||||
"rotate_proxy_session_id": {
|
||||
"type": "boolean",
|
||||
"title": "Rotate Proxy Session Id",
|
||||
"description": "Rotate the Skyvern-managed proxy sticky-session id for this profile.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -25044,12 +25217,13 @@
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"manual",
|
||||
"mcp",
|
||||
"api",
|
||||
"scheduled",
|
||||
"webhook"
|
||||
],
|
||||
"title": "WorkflowRunTriggerType",
|
||||
"description": "How a workflow run was initiated.\n\n- manual: User clicked \"Run\" in the UI\n- api: Direct API call to the run endpoint\n- scheduled: Triggered by a cron schedule\n- webhook: Triggered by an external system via the webhook endpoint"
|
||||
"description": "How a workflow run was initiated.\n\n- manual: User clicked \"Run\" in the UI\n- mcp: First-party MCP client request\n- api: Direct API call to the run endpoint\n- scheduled: Triggered by a cron schedule\n- webhook: Triggered by an external system via the webhook endpoint"
|
||||
},
|
||||
"WorkflowSchedule": {
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -1,286 +0,0 @@
|
|||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
|
||||
- [Skyvern Langchain](#skyvern-langchain)
|
||||
- [Installation](#installation)
|
||||
- [Basic Usage](#basic-usage)
|
||||
- [Run a task(sync) locally in your local environment](#run-a-tasksync-locally-in-your-local-environment)
|
||||
- [Run a task(async) locally in your local environment](#run-a-taskasync-locally-in-your-local-environment)
|
||||
- [Get a task locally in your local environment](#get-a-task-locally-in-your-local-environment)
|
||||
- [Run a task(sync) by calling skyvern APIs](#run-a-tasksync-by-calling-skyvern-apis)
|
||||
- [Run a task(async) by calling skyvern APIs](#run-a-taskasync-by-calling-skyvern-apis)
|
||||
- [Get a task by calling skyvern APIs](#get-a-task-by-calling-skyvern-apis)
|
||||
- [Agent Usage](#agent-usage)
|
||||
- [Run a task(async) locally in your local environment and wait until the task is finished](#run-a-taskasync-locally-in-your-local-environment-and-wait-until-the-task-is-finished)
|
||||
- [Run a task(async) by calling skyvern APIs and wait until the task is finished](#run-a-taskasync-by-calling-skyvern-apis-and-wait-until-the-task-is-finished)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
# Skyvern Langchain
|
||||
|
||||
This is a langchain integration for Skyvern.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install skyvern-langchain
|
||||
```
|
||||
|
||||
To run the example scenarios, you might need to install other langchain dependencies.
|
||||
```bash
|
||||
pip install langchain-openai
|
||||
pip install langchain-community
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
This is the only basic usage of skyvern langchain tool. If you want a full langchain integration experience, please refer to the [Agent Usage](#agent-usage) section to play with langchain agent.
|
||||
|
||||
Go to [Langchain Tools](https://python.langchain.com/v0.1/docs/modules/tools/) to see more advanced langchain tool usage.
|
||||
|
||||
|
||||
### Run a task(sync) locally in your local environment
|
||||
> sync task won't return until the task is finished.
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from skyvern_langchain.agent import RunTask
|
||||
|
||||
run_task = RunTask()
|
||||
|
||||
async def main():
|
||||
# to run skyvern agent locally, must run `skyvern init` first
|
||||
print(await run_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Run a task(async) locally in your local environment
|
||||
> async task will return immediately and the task will be running in the background.
|
||||
|
||||
:warning: :warning: if you want to run the task in the background, you need to keep the script running until the task is finished, otherwise the task will be killed when the script is finished.
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from skyvern_langchain.agent import DispatchTask
|
||||
|
||||
dispatch_task = DispatchTask()
|
||||
|
||||
async def main():
|
||||
# to run skyvern agent locally, must run `skyvern init` first
|
||||
print(await dispatch_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
|
||||
|
||||
# keep the script running until the task is finished
|
||||
await asyncio.sleep(600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
```
|
||||
|
||||
### Get a task locally in your local environment
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from skyvern_langchain.agent import GetTask
|
||||
|
||||
get_task = GetTask()
|
||||
|
||||
async def main():
|
||||
# to run skyvern agent locally, must run `skyvern init` first
|
||||
print(await get_task.ainvoke("<task_id>"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
```
|
||||
|
||||
### Run a task(sync) by calling skyvern APIs
|
||||
> sync task won't return until the task is finished.
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from skyvern_langchain.client import RunTask
|
||||
|
||||
run_task = RunTask(
|
||||
api_key="<your_organization_api_key>",
|
||||
)
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# run_task = RunTask()
|
||||
|
||||
async def main():
|
||||
print(await run_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Run a task(async) by calling skyvern APIs
|
||||
> async task will return immediately and the task will be running in the background.
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
the task is actually running in the skyvern cloud service, so you don't need to keep your script running until the task is finished.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from skyvern_langchain.client import DispatchTask
|
||||
|
||||
dispatch_task = DispatchTask(
|
||||
api_key="<your_organization_api_key>",
|
||||
)
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# dispatch_task = DispatchTask()
|
||||
|
||||
async def main():
|
||||
print(await dispatch_task.ainvoke("Navigate to the Hacker News homepage and get the top 3 posts."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
|
||||
### Get a task by calling skyvern APIs
|
||||
> async task will return immediately and the task will be running in the background.
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
the task is actually running in the skyvern cloud service, so you don't need to keep your script running until the task is finished.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from skyvern_langchain.client import GetTask
|
||||
|
||||
get_task = GetTask(
|
||||
api_key="<your_organization_api_key>",
|
||||
)
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# get_task = GetTask()
|
||||
|
||||
async def main():
|
||||
print(await get_task.ainvoke("<task_id>"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Agent Usage
|
||||
|
||||
Langchain is more powerful when used with [Langchain Agents](https://python.langchain.com/v0.1/docs/modules/agents/).
|
||||
|
||||
The following two examples show how to build an agent that executes a specified task, waits for its completion, and then returns the results. For example, the agent is tasked with navigating to the Hacker News homepage and retrieving the top three posts.
|
||||
|
||||
|
||||
### Run a task(async) locally in your local environment and wait until the task is finished
|
||||
|
||||
> async task will return immediately and the task will be running in the background. You can use `GetTask` tool to poll the task information until the task is finished.
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from skyvern_langchain.agent import DispatchTask, GetTask
|
||||
|
||||
from langchain_community.tools.sleep.tool import SleepTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
||||
|
||||
dispatch_task = DispatchTask()
|
||||
get_task = GetTask()
|
||||
|
||||
agent = initialize_agent(
|
||||
llm=llm,
|
||||
tools=[
|
||||
dispatch_task,
|
||||
get_task,
|
||||
SleepTool(),
|
||||
],
|
||||
verbose=True,
|
||||
agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# use sleep tool to set up the polling logic until the task is completed, if you only want to dispatch a task, you can remove the sleep tool
|
||||
print(await agent.ainvoke("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
```
|
||||
|
||||
### Run a task(async) by calling skyvern APIs and wait until the task is finished
|
||||
|
||||
> async task will return immediately and the task will be running in the background. You can use `GetTask` tool to poll the task information until the task is finished.
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from skyvern_langchain.client import DispatchTask, GetTask
|
||||
|
||||
from langchain_community.tools.sleep.tool import SleepTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
||||
|
||||
dispatch_task = DispatchTask(
|
||||
api_key="<your_organization_api_key>",
|
||||
)
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# dispatch_task = DispatchTask()
|
||||
|
||||
get_task = GetTask(
|
||||
api_key="<your_organization_api_key>",
|
||||
)
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# get_task = GetTask()
|
||||
|
||||
agent = initialize_agent(
|
||||
llm=llm,
|
||||
tools=[
|
||||
dispatch_task,
|
||||
get_task,
|
||||
SleepTool(),
|
||||
],
|
||||
verbose=True,
|
||||
agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# use sleep tool to set up the polling logic until the task is completed, if you only want to dispatch a task, you can remove the sleep tool
|
||||
print(await agent.ainvoke("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
[project]
|
||||
name = "skyvern-langchain"
|
||||
version = "0.2.1"
|
||||
description = ""
|
||||
authors = [{ name = "lawyzheng", email = "lawy@skyvern.com" }]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
readme = "README.md"
|
||||
|
||||
dependencies = [
|
||||
"skyvern>=0.2.0",
|
||||
"langchain>=1.2.0",
|
||||
"langchain-core>=1.3.3",
|
||||
"urllib3>=2.7.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
override-dependencies = [
|
||||
"authlib>=1.6.11",
|
||||
"pyasn1>=0.6.3",
|
||||
"pyjwt>=2.12.0",
|
||||
"fastmcp>=3.2.0",
|
||||
"mcp>=1.23.0",
|
||||
"websockets>=15.0.1",
|
||||
# litellm 1.83.7+ pins these exactly; relax for security upgrade.
|
||||
"jsonschema>=4.23.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"openai>=2.24.0",
|
||||
# Dependabot moderate security upgrades (transitive)
|
||||
"mako>=1.3.11",
|
||||
"pypdf>=6.10.2",
|
||||
"python-multipart>=0.0.26",
|
||||
"cryptography>=46.0.7",
|
||||
"pygments>=2.20.0",
|
||||
# Dependabot high security upgrades (transitive)
|
||||
"litellm>=1.83.10",
|
||||
"langsmith>=0.8.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
skyvern = { path = "../.." }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["twine>=6.1.0,<7"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["skyvern_langchain"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["skyvern_langchain"]
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
from typing import Any, Type
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from litellm import BaseModel
|
||||
from pydantic import Field
|
||||
from skyvern_langchain.schema import CreateTaskInput, GetTaskInput
|
||||
from skyvern_langchain.settings import settings
|
||||
|
||||
from skyvern import Skyvern
|
||||
from skyvern.client.types.get_run_response import GetRunResponse
|
||||
from skyvern.client.types.task_run_response import TaskRunResponse
|
||||
from skyvern.schemas.runs import RunEngine
|
||||
|
||||
|
||||
class SkyvernTaskBaseTool(BaseTool):
|
||||
engine: RunEngine = Field(default=settings.engine)
|
||||
run_task_timeout_seconds: int = Field(default=settings.run_task_timeout_seconds)
|
||||
agent: Skyvern = Skyvern.local()
|
||||
|
||||
def _run(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise NotImplementedError("skyvern task tool does not support sync")
|
||||
|
||||
|
||||
class RunTask(SkyvernTaskBaseTool):
|
||||
name: str = "run-skyvern-agent-task"
|
||||
description: str = """Use Skyvern agent to run a task. This function won't return until the task is finished."""
|
||||
args_schema: Type[BaseModel] = CreateTaskInput
|
||||
|
||||
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
|
||||
return await self.agent.run_task(
|
||||
prompt=user_prompt,
|
||||
url=url,
|
||||
engine=self.engine,
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
|
||||
|
||||
class DispatchTask(SkyvernTaskBaseTool):
|
||||
name: str = "dispatch-skyvern-agent-task"
|
||||
description: str = """Use Skyvern agent to dispatch a task. This function will return immediately and the task will be running in the background."""
|
||||
args_schema: Type[BaseModel] = CreateTaskInput
|
||||
|
||||
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
|
||||
return await self.agent.run_task(
|
||||
prompt=user_prompt,
|
||||
url=url,
|
||||
engine=self.engine,
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
wait_for_completion=False,
|
||||
)
|
||||
|
||||
|
||||
class GetTask(SkyvernTaskBaseTool):
|
||||
name: str = "get-skyvern-agent-task"
|
||||
description: str = """Use Skyvern agent to get a task."""
|
||||
args_schema: Type[BaseModel] = GetTaskInput
|
||||
|
||||
async def _arun(self, task_id: str) -> GetRunResponse | None:
|
||||
return await self.agent.get_run(run_id=task_id)
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
from typing import Any, Type
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
from skyvern_langchain.schema import CreateTaskInput, GetTaskInput
|
||||
from skyvern_langchain.settings import settings
|
||||
|
||||
from skyvern import Skyvern
|
||||
from skyvern.client import SkyvernEnvironment
|
||||
from skyvern.client.types.get_run_response import GetRunResponse
|
||||
from skyvern.client.types.task_run_response import TaskRunResponse
|
||||
from skyvern.schemas.runs import RunEngine
|
||||
|
||||
|
||||
class SkyvernTaskBaseTool(BaseTool):
|
||||
api_key: str = Field(default=settings.api_key)
|
||||
base_url: str = Field(default=settings.base_url)
|
||||
engine: RunEngine = Field(default=settings.engine)
|
||||
run_task_timeout_seconds: int = Field(default=settings.run_task_timeout_seconds)
|
||||
|
||||
def get_client(self) -> Skyvern:
|
||||
return Skyvern(environment=SkyvernEnvironment.CLOUD, base_url=self.base_url, api_key=self.api_key)
|
||||
|
||||
def _run(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise NotImplementedError("skyvern task tool does not support sync")
|
||||
|
||||
|
||||
class RunTask(SkyvernTaskBaseTool):
|
||||
name: str = "run-skyvern-client-task"
|
||||
description: str = """Use Skyvern client to run a task. This function won't return until the task is finished."""
|
||||
args_schema: Type[BaseModel] = CreateTaskInput
|
||||
|
||||
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
|
||||
return await self.get_client().run_task(
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
url=url,
|
||||
prompt=user_prompt,
|
||||
engine=self.engine,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
|
||||
|
||||
class DispatchTask(SkyvernTaskBaseTool):
|
||||
name: str = "dispatch-skyvern-client-task"
|
||||
description: str = """Use Skyvern client to dispatch a task. This function will return immediately and the task will be running in the background."""
|
||||
args_schema: Type[BaseModel] = CreateTaskInput
|
||||
|
||||
async def _arun(self, user_prompt: str, url: str | None = None) -> TaskRunResponse:
|
||||
return await self.get_client().run_task(
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
url=url,
|
||||
prompt=user_prompt,
|
||||
engine=self.engine,
|
||||
wait_for_completion=False,
|
||||
)
|
||||
|
||||
|
||||
class GetTask(SkyvernTaskBaseTool):
|
||||
name: str = "get-skyvern-client-task"
|
||||
description: str = """Use Skyvern client to get a task."""
|
||||
args_schema: Type[BaseModel] = GetTaskInput
|
||||
|
||||
async def _arun(self, task_id: str) -> GetRunResponse | None:
|
||||
return await self.get_client().get_run(run_id=task_id)
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateTaskInput(BaseModel):
|
||||
user_prompt: str
|
||||
url: str | None = None
|
||||
|
||||
|
||||
class GetTaskInput(BaseModel):
|
||||
task_id: str
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
from dotenv import load_dotenv
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from skyvern.schemas.runs import RunEngine
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
api_key: str = ""
|
||||
base_url: str = "https://api.skyvern.com"
|
||||
engine: RunEngine = RunEngine.skyvern_v2
|
||||
run_task_timeout_seconds: int = 60 * 60
|
||||
|
||||
class Config:
|
||||
env_prefix = "SKYVERN_"
|
||||
|
||||
|
||||
load_dotenv()
|
||||
settings = Settings()
|
||||
1453
integrations/langchain/uv.lock
generated
|
|
@ -1,294 +0,0 @@
|
|||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
|
||||
- [Skyvern LlamaIndex](#skyvern-llamaindex)
|
||||
- [Installation](#installation)
|
||||
- [Basic Usage](#basic-usage)
|
||||
- [Run a task(sync) locally in your local environment](#run-a-tasksync-locally-in-your-local-environment)
|
||||
- [Run a task(async) locally in your local environment](#run-a-taskasync-locally-in-your-local-environment)
|
||||
- [Get a task locally in your local environment](#get-a-task-locally-in-your-local-environment)
|
||||
- [Run a task(sync) by calling skyvern APIs](#run-a-tasksync-by-calling-skyvern-apis)
|
||||
- [Run a task(async) by calling skyvern APIs](#run-a-taskasync-by-calling-skyvern-apis)
|
||||
- [Get a task by calling skyvern APIs](#get-a-task-by-calling-skyvern-apis)
|
||||
- [Advanced Usage](#advanced-usage)
|
||||
- [Dispatch a task(async) locally in your local environment and wait until the task is finished](#dispatch-a-taskasync-locally-in-your-local-environment-and-wait-until-the-task-is-finished)
|
||||
- [Dispatch a task(async) by calling skyvern APIs and wait until the task is finished](#dispatch-a-taskasync-by-calling-skyvern-apis-and-wait-until-the-task-is-finished)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
# Skyvern LlamaIndex
|
||||
|
||||
This is a LlamaIndex integration for Skyvern.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install skyvern-llamaindex
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Run a task(sync) locally in your local environment
|
||||
> sync task won't return until the task is finished.
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from skyvern_llamaindex.agent import SkyvernTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.run_task()],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.'")
|
||||
print(response)
|
||||
```
|
||||
|
||||
### Run a task(async) locally in your local environment
|
||||
> async task will return immediately and the task will be running in the background.
|
||||
|
||||
:warning: :warning: if you want to run the task in the background, you need to keep the agent running until the task is finished, otherwise the task will be killed when the agent finished the chat.
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from skyvern_llamaindex.agent import SkyvernTool
|
||||
from llama_index.core.tools import FunctionTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
async def sleep(seconds: int) -> str:
|
||||
await asyncio.sleep(seconds)
|
||||
return f"Slept for {seconds} seconds"
|
||||
|
||||
# define a sleep tool to keep the agent running until the task is finished
|
||||
sleep_tool = FunctionTool.from_defaults(
|
||||
async_fn=sleep,
|
||||
description="Sleep for a given number of seconds",
|
||||
name="sleep",
|
||||
)
|
||||
|
||||
skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.dispatch_task(), sleep_tool],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, sleep for 10 minutes.")
|
||||
print(response)
|
||||
```
|
||||
|
||||
### Get a task locally in your local environment
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from skyvern_llamaindex.agent import SkyvernTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.get_task()],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("Get the task information with Skyvern. The task id is '<task_id>'.")
|
||||
print(response)
|
||||
```
|
||||
|
||||
### Run a task(sync) by calling skyvern APIs
|
||||
> sync task won't return until the task is finished.
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from skyvern_llamaindex.client import SkyvernTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.run_task()],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.'")
|
||||
print(response)
|
||||
```
|
||||
|
||||
### Run a task(async) by calling skyvern APIs
|
||||
> async task will return immediately and the task will be running in the background.
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
the task is actually running in the skyvern cloud service, so you don't need to keep your agent running until the task is finished.
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from skyvern_llamaindex.client import SkyvernTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.dispatch_task()],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.'")
|
||||
print(response)
|
||||
```
|
||||
|
||||
|
||||
### Get a task by calling skyvern APIs
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from skyvern_llamaindex.client import SkyvernTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.get_task()],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("Get the task information with Skyvern. The task id is '<task_id>'.")
|
||||
print(response)
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
To provide some examples of how to integrate Skyvern with other llama-index tools in the agent.
|
||||
|
||||
### Dispatch a task(async) locally in your local environment and wait until the task is finished
|
||||
> dispatch task will return immediately and the task will be running in the background. You can use `get_task` tool to poll the task information until the task is finished.
|
||||
|
||||
:warning: :warning: if you want to run this code block, you need to run `skyvern init` command in your terminal to set up skyvern first.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from llama_index.core.tools import FunctionTool
|
||||
from skyvern_llamaindex.agent import SkyvernTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
async def sleep(seconds: int) -> str:
|
||||
await asyncio.sleep(seconds)
|
||||
return f"Slept for {seconds} seconds"
|
||||
|
||||
sleep_tool = FunctionTool.from_defaults(
|
||||
async_fn=sleep,
|
||||
description="Sleep for a given number of seconds",
|
||||
name="sleep",
|
||||
)
|
||||
|
||||
skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.dispatch_task(), skyvern_tool.get_task(), sleep_tool],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
max_function_calls=10,
|
||||
)
|
||||
|
||||
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s.")
|
||||
print(response)
|
||||
|
||||
```
|
||||
|
||||
### Dispatch a task(async) by calling skyvern APIs and wait until the task is finished
|
||||
> dispatch task will return immediately and the task will be running in the background. You can use `get_task` tool to poll the task information until the task is finished.
|
||||
|
||||
no need to run `skyvern init` command in your terminal to set up skyvern before using this integration.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from llama_index.agent.openai import OpenAIAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from llama_index.core.tools import FunctionTool
|
||||
from skyvern_llamaindex.client import SkyvernTool
|
||||
|
||||
# load OpenAI API key from .env
|
||||
load_dotenv()
|
||||
|
||||
async def sleep(seconds: int) -> str:
|
||||
await asyncio.sleep(seconds)
|
||||
return f"Slept for {seconds} seconds"
|
||||
|
||||
sleep_tool = FunctionTool.from_defaults(
|
||||
async_fn=sleep,
|
||||
description="Sleep for a given number of seconds",
|
||||
name="sleep",
|
||||
)
|
||||
|
||||
skyvern_tool = SkyvernTool(api_key="<your_organization_api_key>")
|
||||
# or you can load the api_key from SKYVERN_API_KEY in .env
|
||||
# skyvern_tool = SkyvernTool()
|
||||
|
||||
agent = OpenAIAgent.from_tools(
|
||||
tools=[skyvern_tool.dispatch_task(), skyvern_tool.get_task(), sleep_tool],
|
||||
llm=OpenAI(model="gpt-4o"),
|
||||
verbose=True,
|
||||
max_function_calls=10,
|
||||
)
|
||||
|
||||
response = agent.chat("Run a task with Skyvern. The task is about 'Navigate to the Hacker News homepage and get the top 3 posts.' Then, get this task information until it's completed. The task information re-get interval should be 60s.")
|
||||
print(response)
|
||||
|
||||
```
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
[project]
|
||||
name = "skyvern-llamaindex"
|
||||
version = "0.2.2"
|
||||
description = "Skyvern integration for LlamaIndex"
|
||||
authors = [{ name = "lawyzheng", email = "lawy@skyvern.com" }]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
readme = "README.md"
|
||||
dependencies = [
|
||||
"skyvern>=0.2.0",
|
||||
"llama-index>=0.12.19,<0.14",
|
||||
# Dependabot #645 (GHSA-qccp-gfcp-xxvc / CVE-2026-44431): urllib3 < 2.7.0
|
||||
# forwards sensitive headers across origins on proxied low-level redirects.
|
||||
"urllib3>=2.7.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["twine>=6.1.0,<7"]
|
||||
|
||||
# Security: override vulnerable transitive dependency versions (SKY-8441)
|
||||
# Using override-dependencies because several of these conflict with
|
||||
# skyvern's pinned ranges (e.g. pypdf<6, python-multipart<0.0.19).
|
||||
[tool.uv]
|
||||
override-dependencies = [
|
||||
"authlib>=1.6.11",
|
||||
"pyasn1>=0.6.3",
|
||||
"PyJWT>=2.12.0",
|
||||
"fastmcp>=3.2.0",
|
||||
"mcp>=1.23.0",
|
||||
"pypdf>=6.10.2",
|
||||
"pillow>=12.1.1",
|
||||
"nltk>=3.9.3",
|
||||
"python-multipart>=0.0.26",
|
||||
"starlette>=0.49.1",
|
||||
"google-cloud-aiplatform>=1.133.0",
|
||||
# Cascading overrides needed to unblock the above security constraints:
|
||||
# fastmcp>=3.2.0 requires websockets>=15 (skyvern pins <13)
|
||||
"websockets>=15.0.1",
|
||||
# litellm 1.83.7+ pins openai==2.24.0 (security upgrade); override
|
||||
# llama-index-llms-openai's openai<2 declaration so the integration can
|
||||
# take the patched litellm.
|
||||
"openai>=2.24.0",
|
||||
# litellm 1.83.7+ also pins these exactly.
|
||||
"jsonschema>=4.23.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
# Dependabot moderate security upgrades (transitive)
|
||||
"mako>=1.3.11",
|
||||
"cryptography>=46.0.7",
|
||||
"pygments>=2.20.0",
|
||||
# Dependabot #631 (GHSA-gphh-9q3h-jgpp / CVE-2026-44209): banks <= 2.4.1
|
||||
# renders prompt templates with an unsandboxed Jinja2 environment (SSTI -> RCE).
|
||||
# Pulled in transitively via llama-index-core. Fixed in 2.4.2.
|
||||
"banks>=2.4.2",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
skyvern = { path = "../.." }
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["skyvern_llamaindex"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["skyvern_llamaindex"]
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
from typing import Any, List
|
||||
|
||||
from llama_index.core.tools import FunctionTool
|
||||
from llama_index.core.tools.tool_spec.base import SPEC_FUNCTION_TYPE, BaseToolSpec
|
||||
from skyvern_llamaindex.settings import settings
|
||||
|
||||
from skyvern import Skyvern
|
||||
from skyvern.client.types.get_run_response import GetRunResponse
|
||||
from skyvern.client.types.task_run_response import TaskRunResponse
|
||||
from skyvern.schemas.runs import RunEngine
|
||||
|
||||
|
||||
class SkyvernTool:
|
||||
def __init__(self, agent: Skyvern | None = None):
|
||||
if agent is None:
|
||||
agent = Skyvern.local()
|
||||
self.agent = agent
|
||||
|
||||
def run_task(self) -> FunctionTool:
|
||||
task_tool_spec = SkyvernTaskToolSpec(agent=self.agent)
|
||||
return task_tool_spec.to_tool_list(["run_task"])[0]
|
||||
|
||||
def dispatch_task(self) -> FunctionTool:
|
||||
task_tool_spec = SkyvernTaskToolSpec(agent=self.agent)
|
||||
return task_tool_spec.to_tool_list(["dispatch_task"])[0]
|
||||
|
||||
def get_task(self) -> FunctionTool:
|
||||
task_tool_spec = SkyvernTaskToolSpec(agent=self.agent)
|
||||
return task_tool_spec.to_tool_list(["get_task"])[0]
|
||||
|
||||
|
||||
class SkyvernTaskToolSpec(BaseToolSpec):
|
||||
spec_functions: List[SPEC_FUNCTION_TYPE] = [
|
||||
"run_task",
|
||||
"dispatch_task",
|
||||
"get_task",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
agent: Skyvern | None = None,
|
||||
engine: RunEngine = settings.engine,
|
||||
run_task_timeout_seconds: int = settings.run_task_timeout_seconds,
|
||||
) -> None:
|
||||
if agent is None:
|
||||
agent = Skyvern.local()
|
||||
self.agent = agent
|
||||
self.engine = engine
|
||||
self.run_task_timeout_seconds = run_task_timeout_seconds
|
||||
|
||||
async def run_task(
|
||||
self,
|
||||
user_prompt: str | None = None,
|
||||
url: str | None = None,
|
||||
*_: Any,
|
||||
**kw: Any,
|
||||
) -> TaskRunResponse:
|
||||
"""
|
||||
Use Skyvern agent to run a task. This function won't return until the task is finished.
|
||||
|
||||
Args:
|
||||
user_prompt[str]: The user's prompt describing the task.
|
||||
url (Optional[str]): The URL of the target website for the task.
|
||||
"""
|
||||
if user_prompt is None and kw.get("args"):
|
||||
user_prompt = kw["args"][0]
|
||||
|
||||
if url is None:
|
||||
if kw.get("args") and len(kw["args"]) > 1:
|
||||
url = kw["args"][1]
|
||||
elif kw.get("kwargs"):
|
||||
url = kw["kwargs"].get("url")
|
||||
|
||||
assert user_prompt is not None, "user_prompt is required"
|
||||
|
||||
return await self.agent.run_task(
|
||||
prompt=user_prompt,
|
||||
url=url,
|
||||
engine=self.engine,
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
|
||||
async def dispatch_task(
|
||||
self,
|
||||
user_prompt: str | None = None,
|
||||
url: str | None = None,
|
||||
*_: Any,
|
||||
**kw: Any,
|
||||
) -> TaskRunResponse:
|
||||
"""
|
||||
Use Skyvern agent to dispatch a task. This function will return immediately and the task will be running in the background.
|
||||
|
||||
Args:
|
||||
user_prompt[str]: The user's prompt describing the task.
|
||||
url (Optional[str]): The URL of the target website for the task.
|
||||
"""
|
||||
if user_prompt is None and kw.get("args"):
|
||||
user_prompt = kw["args"][0]
|
||||
|
||||
if url is None:
|
||||
if kw.get("args") and len(kw["args"]) > 1:
|
||||
url = kw["args"][1]
|
||||
elif kw.get("kwargs"):
|
||||
url = kw["kwargs"].get("url")
|
||||
|
||||
assert user_prompt is not None, "user_prompt is required"
|
||||
|
||||
return await self.agent.run_task(
|
||||
prompt=user_prompt,
|
||||
url=url,
|
||||
engine=self.engine,
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
wait_for_completion=False,
|
||||
)
|
||||
|
||||
async def get_task(self, task_id: str | None = None, *_: Any, **kwargs: Any) -> GetRunResponse | None:
|
||||
"""
|
||||
Use Skyvern agent to get a task.
|
||||
|
||||
Args:
|
||||
task_id[str]: The id of the task.
|
||||
"""
|
||||
if task_id is None and "args" in kwargs:
|
||||
task_id = kwargs["args"][0]
|
||||
|
||||
assert task_id is not None, "task_id is required"
|
||||
return await self.agent.get_run(run_id=task_id)
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
from typing import Any, List
|
||||
|
||||
from llama_index.core.tools import FunctionTool
|
||||
from llama_index.core.tools.tool_spec.base import SPEC_FUNCTION_TYPE, BaseToolSpec
|
||||
from pydantic import BaseModel
|
||||
from skyvern_llamaindex.settings import settings
|
||||
|
||||
from skyvern import Skyvern
|
||||
from skyvern.client import SkyvernEnvironment
|
||||
from skyvern.client.types.get_run_response import GetRunResponse
|
||||
from skyvern.client.types.task_run_response import TaskRunResponse
|
||||
from skyvern.schemas.runs import RunEngine
|
||||
|
||||
|
||||
class SkyvernTool(BaseModel):
|
||||
api_key: str = settings.api_key
|
||||
base_url: str = settings.base_url
|
||||
|
||||
def run_task(self) -> FunctionTool:
|
||||
task_tool_spec = SkyvernTaskToolSpec(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
|
||||
return task_tool_spec.to_tool_list(["run_task"])[0]
|
||||
|
||||
def dispatch_task(self) -> FunctionTool:
|
||||
task_tool_spec = SkyvernTaskToolSpec(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
|
||||
return task_tool_spec.to_tool_list(["dispatch_task"])[0]
|
||||
|
||||
def get_task(self) -> FunctionTool:
|
||||
task_tool_spec = SkyvernTaskToolSpec(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
|
||||
return task_tool_spec.to_tool_list(["get_task"])[0]
|
||||
|
||||
|
||||
class SkyvernTaskToolSpec(BaseToolSpec):
|
||||
spec_functions: List[SPEC_FUNCTION_TYPE] = [
|
||||
"run_task",
|
||||
"dispatch_task",
|
||||
"get_task",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str = settings.api_key,
|
||||
base_url: str = settings.base_url,
|
||||
engine: RunEngine = settings.engine,
|
||||
run_task_timeout_seconds: int = settings.run_task_timeout_seconds,
|
||||
):
|
||||
self.engine = engine
|
||||
self.run_task_timeout_seconds = run_task_timeout_seconds
|
||||
self.client = Skyvern(environment=SkyvernEnvironment.CLOUD, base_url=base_url, api_key=api_key)
|
||||
|
||||
async def run_task(
|
||||
self,
|
||||
user_prompt: str | None = None,
|
||||
url: str | None = None,
|
||||
*_: Any,
|
||||
**kw: Any,
|
||||
) -> TaskRunResponse:
|
||||
"""
|
||||
Use Skyvern client to run a task. This function won't return until the task is finished.
|
||||
|
||||
Args:
|
||||
user_prompt[str]: The user's prompt describing the task.
|
||||
url (Optional[str]): The URL of the target website for the task.
|
||||
"""
|
||||
if user_prompt is None and kw.get("args"):
|
||||
user_prompt = kw["args"][0]
|
||||
|
||||
if url is None:
|
||||
if kw.get("args") and len(kw["args"]) > 1:
|
||||
url = kw["args"][1]
|
||||
elif kw.get("kwargs"):
|
||||
url = kw["kwargs"].get("url")
|
||||
|
||||
assert user_prompt is not None, "user_prompt is required"
|
||||
|
||||
return await self.client.run_task(
|
||||
prompt=user_prompt,
|
||||
url=url,
|
||||
engine=self.engine,
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
|
||||
async def dispatch_task(
|
||||
self,
|
||||
user_prompt: str | None = None,
|
||||
url: str | None = None,
|
||||
*_: Any,
|
||||
**kw: Any,
|
||||
) -> TaskRunResponse:
|
||||
"""
|
||||
Use Skyvern client to dispatch a task. This function will return immediately and the task will be running in the background.
|
||||
|
||||
Args:
|
||||
user_prompt[str]: The user's prompt describing the task.
|
||||
url (Optional[str]): The URL of the target website for the task.
|
||||
"""
|
||||
if user_prompt is None and kw.get("args"):
|
||||
user_prompt = kw["args"][0]
|
||||
|
||||
if url is None:
|
||||
if kw.get("args") and len(kw["args"]) > 1:
|
||||
url = kw["args"][1]
|
||||
elif kw.get("kwargs"):
|
||||
url = kw["kwargs"].get("url")
|
||||
|
||||
assert user_prompt is not None, "user_prompt is required"
|
||||
return await self.client.run_task(
|
||||
prompt=user_prompt,
|
||||
url=url,
|
||||
engine=self.engine,
|
||||
timeout=self.run_task_timeout_seconds,
|
||||
wait_for_completion=False,
|
||||
)
|
||||
|
||||
async def get_task(self, task_id: str | None = None, *_: Any, **kwargs: Any) -> GetRunResponse | None:
|
||||
"""
|
||||
Use Skyvern client to get a task.
|
||||
|
||||
Args:
|
||||
task_id[str]: The id of the task.
|
||||
"""
|
||||
if task_id is None and "args" in kwargs:
|
||||
task_id = kwargs["args"][0]
|
||||
|
||||
assert task_id is not None, "task_id is required"
|
||||
return await self.client.get_run(run_id=task_id)
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
from dotenv import load_dotenv
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from skyvern.schemas.runs import RunEngine
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
api_key: str = ""
|
||||
base_url: str = "https://api.skyvern.com"
|
||||
engine: RunEngine = RunEngine.skyvern_v2
|
||||
run_task_timeout_seconds: int = 60 * 60
|
||||
|
||||
class Config:
|
||||
env_prefix = "SKYVERN_"
|
||||
|
||||
|
||||
load_dotenv()
|
||||
settings = Settings()
|
||||
2596
integrations/llama_index/uv.lock
generated
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "skyvern-ui"
|
||||
version = "1.0.44"
|
||||
version = "1.0.46"
|
||||
description = "Prebuilt Skyvern UI assets for the Skyvern CLI"
|
||||
authors = [{ name = "Skyvern AI", email = "info@skyvern.com" }]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "skyvern"
|
||||
version = "1.0.44"
|
||||
version = "1.0.46"
|
||||
description = ""
|
||||
authors = [{ name = "Skyvern AI", email = "info@skyvern.com" }]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
|
|
@ -135,7 +135,9 @@ server = [
|
|||
"json-repair>=0.59.5,<0.60",
|
||||
"pypdf>=6.12.0,<7",
|
||||
"pdfplumber>=0.11.0,<0.12",
|
||||
"fastmcp>=3.2.0,<4",
|
||||
# <3.4: 3.4.3 enables HostOriginGuardMiddleware by default (421 on public hosts, SKY-12003).
|
||||
# cloud_app passes allowed_hosts/allowed_origins when supported; validate against 3.4.x before raising.
|
||||
"fastmcp>=3.2.0,<3.4",
|
||||
"psutil>=7.0.0",
|
||||
"tiktoken>=0.9.0",
|
||||
"anthropic>=0.97.0,<0.98",
|
||||
|
|
@ -281,10 +283,11 @@ constraint-dependencies = [
|
|||
"cryptography>=48.0.1",
|
||||
"flask>=3.1.3",
|
||||
"idna>=3.15",
|
||||
"joserfc>=1.6.3",
|
||||
"jupyter-server>=2.18.0",
|
||||
"joserfc>=1.6.8",
|
||||
"jupyter-server>=2.20.0",
|
||||
"mako>=1.3.12",
|
||||
"mistune>=3.2.1",
|
||||
"msgpack>=1.2.1",
|
||||
"pyasn1>=0.6.3",
|
||||
"starlette>=1.3.1",
|
||||
"tornado>=6.5.6",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@
|
|||
export DATABASE_STRING
|
||||
|
||||
# first apply migrations
|
||||
export PATH="${PATH}:.venv/bin"
|
||||
# Prepend the repo venv so its alembic wins over a global install (e.g. Homebrew's),
|
||||
# which lacks the `cloud` package and would flag cloud tables as drift.
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export PATH="${SCRIPT_DIR}/.venv/bin:${PATH}"
|
||||
alembic upgrade head
|
||||
# then check if the database is up to date with the models
|
||||
if ! alembic check; then
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ async def run() -> None:
|
|||
|
||||
try:
|
||||
if workflow_run_id:
|
||||
workflow_run = await app.DATABASE.get_workflow_run(workflow_run_id=workflow_run_id)
|
||||
workflow_run = await app.DATABASE.workflow_runs.get_workflow_run(workflow_run_id=workflow_run_id)
|
||||
if not workflow_run or workflow_run.status in [
|
||||
WorkflowRunStatus.completed,
|
||||
WorkflowRunStatus.failed,
|
||||
|
|
@ -44,7 +44,7 @@ async def run() -> None:
|
|||
file_name = f"{workflow_run_id}.png"
|
||||
|
||||
elif task_id:
|
||||
task = await app.DATABASE.get_task(task_id=task_id, organization_id=organization_id)
|
||||
task = await app.DATABASE.tasks.get_task(task_id=task_id, organization_id=organization_id)
|
||||
if not task or task.status.is_final():
|
||||
continue
|
||||
file_name = f"{task_id}.png"
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ delegate_methods=$(
|
|||
)
|
||||
|
||||
if [ -z "$delegate_methods" ]; then
|
||||
echo "ERROR: Could not extract delegate methods from $AGENT_DB" >&2
|
||||
exit 1
|
||||
# The AgentDB *args backward-compat facade was removed entirely (SKY-11703).
|
||||
# With no delegate methods left, there is nothing to police: a direct
|
||||
# delegate call is now a plain AttributeError/mypy error, caught elsewhere.
|
||||
# Nothing to check, so pass cleanly.
|
||||
echo "No AgentDB delegate methods defined; nothing to check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build a grep alternation pattern for delegate method names.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ rm -fr skyvern/client
|
|||
mkdir -p skyvern/client
|
||||
cp -rf fern/.preview/fern-python-sdk/src/skyvern/* skyvern/client/
|
||||
|
||||
# Post-processing: Reapply manual patches (circular imports, forward-ref KeyError)
|
||||
python3 scripts/patch_generated_client.py || exit 1
|
||||
|
||||
# Post-processing: Patch version.py to handle missing metadata gracefully
|
||||
VERSION_FILE="skyvern/client/version.py"
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
|
|
|
|||
132
scripts/patch_generated_client.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reapply manual patches to the Fern-generated Python client.
|
||||
|
||||
Run automatically by scripts/fern_build_python_sdk.sh after every regen.
|
||||
Idempotent: running it twice leaves the files unchanged.
|
||||
|
||||
Patches:
|
||||
1. Loop-block circular imports — Fern v4.31.1 emits bottom-cross-imports that
|
||||
deadlock at module load (for_loop <-> while_loop <-> *_loop_blocks_item).
|
||||
Wrap them in try/except ImportError; the symmetric *_loop_blocks_item module
|
||||
back-resolves once both unions are fully defined.
|
||||
2. update_forward_refs — Pydantic v2 can raise internal schema-gathering
|
||||
KeyErrors for Fern-generated recursive unions even with raise_errors=False.
|
||||
Suppress KeyErrors that mention the "definitions" key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CLIENT = Path(__file__).resolve().parent.parent / "skyvern" / "client"
|
||||
|
||||
LOOP_BLOCK_PATCHES = {
|
||||
"types/for_loop_block.py": (
|
||||
"""from .while_loop_block import WhileLoopBlock # noqa: E402, F401, I001
|
||||
from .for_loop_block_loop_blocks_item import ForLoopBlockLoopBlocksItem # noqa: E402, F401, I001""",
|
||||
"""
|
||||
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
|
||||
# load (for_loop <-> while_loop <-> *_loop_blocks_item). Catch the mid-load
|
||||
# ImportError; the symmetric *_loop_blocks_item module back-resolves once both
|
||||
# unions are fully defined. Reapplied on every regen by scripts/patch_generated_client.py.
|
||||
try:
|
||||
from .while_loop_block import WhileLoopBlock # noqa: E402, F401, I001
|
||||
from .for_loop_block_loop_blocks_item import ForLoopBlockLoopBlocksItem # noqa: E402, F401, I001
|
||||
except ImportError:
|
||||
pass""",
|
||||
),
|
||||
"types/while_loop_block.py": (
|
||||
"""from .for_loop_block import ForLoopBlock # noqa: E402, F401, I001
|
||||
from .while_loop_block_loop_blocks_item import WhileLoopBlockLoopBlocksItem # noqa: E402, F401, I001""",
|
||||
"""
|
||||
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
|
||||
# load. See for_loop_block.py for the explanation.
|
||||
try:
|
||||
from .for_loop_block import ForLoopBlock # noqa: E402, F401, I001
|
||||
from .while_loop_block_loop_blocks_item import WhileLoopBlockLoopBlocksItem # noqa: E402, F401, I001
|
||||
except ImportError:
|
||||
pass""",
|
||||
),
|
||||
"types/for_loop_block_yaml.py": (
|
||||
"""from .while_loop_block_yaml import WhileLoopBlockYaml # noqa: E402, F401, I001
|
||||
from .for_loop_block_yaml_loop_blocks_item import ForLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001""",
|
||||
"""
|
||||
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
|
||||
# load. See for_loop_block.py for the explanation.
|
||||
try:
|
||||
from .while_loop_block_yaml import WhileLoopBlockYaml # noqa: E402, F401, I001
|
||||
from .for_loop_block_yaml_loop_blocks_item import ForLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001
|
||||
except ImportError:
|
||||
pass""",
|
||||
),
|
||||
"types/while_loop_block_yaml.py": (
|
||||
"""from .for_loop_block_yaml import ForLoopBlockYaml # noqa: E402, F401, I001
|
||||
from .while_loop_block_yaml_loop_blocks_item import WhileLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001""",
|
||||
"""
|
||||
# Manual patch: Fern v4.31.1 emits bottom-cross-imports that deadlock at module
|
||||
# load. See for_loop_block.py for the explanation.
|
||||
try:
|
||||
from .for_loop_block_yaml import ForLoopBlockYaml # noqa: E402, F401, I001
|
||||
from .while_loop_block_yaml_loop_blocks_item import WhileLoopBlockYamlLoopBlocksItem # noqa: E402, F401, I001
|
||||
except ImportError:
|
||||
pass""",
|
||||
),
|
||||
}
|
||||
|
||||
FORWARD_REFS_OLD = """def update_forward_refs(model: Type["Model"], **localns: Any) -> None:
|
||||
if IS_PYDANTIC_V2:
|
||||
model.model_rebuild(raise_errors=False) # type: ignore[attr-defined]
|
||||
else:
|
||||
model.update_forward_refs(**localns)"""
|
||||
|
||||
FORWARD_REFS_NEW = """def update_forward_refs(model: Type["Model"], **localns: Any) -> None:
|
||||
if IS_PYDANTIC_V2:
|
||||
try:
|
||||
model.model_rebuild(raise_errors=False) # type: ignore[attr-defined]
|
||||
except KeyError as exc:
|
||||
# Manual patch (reapplied by scripts/patch_generated_client.py):
|
||||
# Pydantic v2 can still raise internal schema-gathering KeyErrors
|
||||
# for Fern-generated recursive unions even with raise_errors=False.
|
||||
# Match on the "definitions" key rather than the exact args tuple so a
|
||||
# Pydantic format change that adds context can't reintroduce the crash.
|
||||
if "definitions" not in exc.args:
|
||||
raise
|
||||
else:
|
||||
model.update_forward_refs(**localns)"""
|
||||
|
||||
|
||||
def patch_file(rel_path: str, old: str, new: str) -> str:
|
||||
path = CLIENT / rel_path
|
||||
text = path.read_text()
|
||||
if new in text:
|
||||
return "already patched"
|
||||
if old not in text:
|
||||
return "PATTERN NOT FOUND"
|
||||
path.write_text(text.replace(old, new, 1))
|
||||
return "patched"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failed = False
|
||||
for rel_path, (old, new) in LOOP_BLOCK_PATCHES.items():
|
||||
result = patch_file(rel_path, old, new)
|
||||
print(f"{rel_path}: {result}")
|
||||
failed |= result == "PATTERN NOT FOUND"
|
||||
|
||||
result = patch_file("core/pydantic_utilities.py", FORWARD_REFS_OLD, FORWARD_REFS_NEW)
|
||||
print(f"core/pydantic_utilities.py: {result}")
|
||||
failed |= result == "PATTERN NOT FOUND"
|
||||
|
||||
if failed:
|
||||
print(
|
||||
"ERROR: some patch patterns no longer match the generated code. "
|
||||
"The Fern generator output changed shape - update this script.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
77
skyvern-frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/merge": "^6.8.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -97,7 +98,7 @@
|
|||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.4.2",
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -364,6 +365,21 @@
|
|||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-yaml": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
|
||||
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"@lezer/yaml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
|
|
@ -1427,6 +1443,17 @@
|
|||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
|
||||
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
|
|
@ -6504,21 +6531,33 @@
|
|||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -7192,6 +7231,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
|
|
@ -9788,16 +9837,6 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
|
@ -9957,9 +9996,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/merge": "^6.8.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -108,7 +109,7 @@
|
|||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.4.2",
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
|
@ -128,6 +129,7 @@
|
|||
},
|
||||
"@types/node": "$@types/node",
|
||||
"protobufjs": ">=7.5.8",
|
||||
"qs": ">=6.15.2"
|
||||
"qs": ">=6.15.2",
|
||||
"undici": "^7.28.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,26 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { isTransientNetworkError } from "./transientNetworkError";
|
||||
|
||||
const MAX_TRANSIENT_NETWORK_RETRIES = 2;
|
||||
|
||||
function retryTransientNetworkFailures(
|
||||
failureCount: number,
|
||||
error: unknown,
|
||||
): boolean {
|
||||
return (
|
||||
isTransientNetworkError(error) &&
|
||||
failureCount < MAX_TRANSIENT_NETWORK_RETRIES
|
||||
);
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: false,
|
||||
retry: retryTransientNetworkFailures,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { queryClient };
|
||||
export { queryClient, retryTransientNetworkFailures };
|
||||
|
|
|
|||
67
skyvern-frontend/src/api/transientNetworkError.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { AxiosError, AxiosHeaders, CanceledError } from "axios";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { retryTransientNetworkFailures } from "./QueryClient";
|
||||
import { isTransientNetworkError } from "./transientNetworkError";
|
||||
|
||||
function axiosErrorWithStatus(status: number): AxiosError {
|
||||
const error = new AxiosError("request failed");
|
||||
error.response = {
|
||||
status,
|
||||
statusText: "",
|
||||
data: null,
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
};
|
||||
return error;
|
||||
}
|
||||
|
||||
describe("isTransientNetworkError", () => {
|
||||
it("matches axios errors without a response (transport failures)", () => {
|
||||
expect(isTransientNetworkError(new AxiosError("Network Error"))).toBe(true);
|
||||
expect(
|
||||
isTransientNetworkError(
|
||||
new AxiosError("Network Error", AxiosError.ERR_NETWORK),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes cancellations even though they lack a response", () => {
|
||||
expect(isTransientNetworkError(new CanceledError())).toBe(false);
|
||||
expect(
|
||||
isTransientNetworkError(
|
||||
new AxiosError("canceled", AxiosError.ERR_CANCELED),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes HTTP error responses", () => {
|
||||
expect(isTransientNetworkError(axiosErrorWithStatus(500))).toBe(false);
|
||||
expect(isTransientNetworkError(axiosErrorWithStatus(404))).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes non-axios errors", () => {
|
||||
expect(isTransientNetworkError(new Error("boom"))).toBe(false);
|
||||
expect(isTransientNetworkError("nope")).toBe(false);
|
||||
expect(isTransientNetworkError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryTransientNetworkFailures", () => {
|
||||
it("retries transport failures up to the cap", () => {
|
||||
const networkError = new AxiosError(
|
||||
"Network Error",
|
||||
AxiosError.ERR_NETWORK,
|
||||
);
|
||||
expect(retryTransientNetworkFailures(0, networkError)).toBe(true);
|
||||
expect(retryTransientNetworkFailures(1, networkError)).toBe(true);
|
||||
expect(retryTransientNetworkFailures(2, networkError)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry HTTP errors or cancellations", () => {
|
||||
expect(retryTransientNetworkFailures(0, axiosErrorWithStatus(500))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(retryTransientNetworkFailures(0, new CanceledError())).toBe(false);
|
||||
});
|
||||
});
|
||||
13
skyvern-frontend/src/api/transientNetworkError.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { AxiosError, isAxiosError, isCancel } from "axios";
|
||||
|
||||
function isTransientNetworkError(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
if (isCancel(error) || error.code === AxiosError.ERR_CANCELED) {
|
||||
return false;
|
||||
}
|
||||
return error.response === undefined;
|
||||
}
|
||||
|
||||
export { isTransientNetworkError };
|
||||
|
|
@ -31,6 +31,7 @@ export type ArtifactType = (typeof ArtifactType)[keyof typeof ArtifactType];
|
|||
|
||||
export const TriggerType = {
|
||||
Manual: "manual",
|
||||
Mcp: "mcp",
|
||||
Api: "api",
|
||||
Scheduled: "scheduled",
|
||||
} as const;
|
||||
|
|
@ -289,6 +290,47 @@ export type ClearOrganizationAuthTokenResponse = {
|
|||
success: boolean;
|
||||
};
|
||||
|
||||
export type CustomLLMProvider = "openai_compatible" | "ollama" | "openrouter";
|
||||
|
||||
export type CustomLLMConfig = {
|
||||
display_name: string;
|
||||
provider: CustomLLMProvider;
|
||||
model_name: string;
|
||||
api_base?: string | null;
|
||||
api_key?: string | null;
|
||||
api_version?: string | null;
|
||||
supports_vision: boolean;
|
||||
add_assistant_prefix: boolean;
|
||||
max_completion_tokens?: number | null;
|
||||
temperature?: number | null;
|
||||
reasoning_effort?: string | null;
|
||||
};
|
||||
|
||||
export type CustomLLM = {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
config: CustomLLMConfig;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
export type CustomLLMListResponse = {
|
||||
custom_llms: Array<CustomLLM>;
|
||||
};
|
||||
|
||||
export type CustomLLMResponse = {
|
||||
custom_llm: CustomLLM;
|
||||
};
|
||||
|
||||
export type CustomLLMCreateRequest = {
|
||||
config: CustomLLMConfig;
|
||||
};
|
||||
|
||||
export type CustomLLMUpdateRequest = {
|
||||
config: CustomLLMConfig;
|
||||
};
|
||||
|
||||
export interface AzureClientSecretCredential {
|
||||
tenant_id: string;
|
||||
client_id: string;
|
||||
|
|
@ -654,6 +696,7 @@ export type TaskRunListItem = {
|
|||
workflow_permanent_id: string | null;
|
||||
workflow_deleted: boolean;
|
||||
script_run: boolean;
|
||||
trigger_type?: TriggerType | null;
|
||||
searchable_text: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -679,6 +722,7 @@ export type WorkflowRunStatusApiResponse = {
|
|||
webhook_failure_reason: string | null;
|
||||
downloaded_file_urls: Array<string> | null;
|
||||
downloaded_files: Array<DownloadedFileInfo> | null;
|
||||
errors: Array<Record<string, unknown>> | null;
|
||||
total_steps: number | null;
|
||||
total_cost: number | null;
|
||||
credits_used: number;
|
||||
|
|
@ -716,6 +760,7 @@ export type WorkflowRunStatusApiResponseWithWorkflow = {
|
|||
webhook_failure_reason: string | null;
|
||||
downloaded_file_urls: Array<string> | null;
|
||||
downloaded_files: Array<DownloadedFileInfo> | null;
|
||||
errors: Array<Record<string, unknown>> | null;
|
||||
total_steps: number | null;
|
||||
total_cost: number | null;
|
||||
credits_used: number;
|
||||
|
|
@ -799,6 +844,8 @@ export type BrowserProfileApiResponse = {
|
|||
source_browser_type: string | null;
|
||||
proxy_location?: ProxyLocation | null;
|
||||
proxy_session_id?: string | null;
|
||||
is_managed?: boolean;
|
||||
workflow_permanent_id?: string | null;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
deleted_at: string | null;
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
import { type ReactElement } from "react";
|
||||
import { type ReactElement, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -9,6 +9,11 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { copyText } from "@/util/copyText";
|
||||
import {
|
||||
|
|
@ -25,6 +30,8 @@ type ApiWebhookActionsMenuProps = {
|
|||
// Single element forwarded to the trigger via Radix `asChild`; when set,
|
||||
// `size` is unused. Defaults to the solid secondary button (legacy pages).
|
||||
trigger?: ReactElement;
|
||||
// Radix tooltip on the trigger; the host must provide a TooltipProvider.
|
||||
triggerTooltip?: ReactNode;
|
||||
};
|
||||
|
||||
export function ApiWebhookActionsMenu({
|
||||
|
|
@ -33,16 +40,27 @@ export function ApiWebhookActionsMenu({
|
|||
onTestWebhook,
|
||||
size = "default",
|
||||
trigger,
|
||||
triggerTooltip,
|
||||
}: ApiWebhookActionsMenuProps) {
|
||||
const menuTrigger = (
|
||||
<DropdownMenuTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button type="button" variant="secondary" size={size}>
|
||||
API & Webhooks
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button type="button" variant="secondary" size={size}>
|
||||
API & Webhooks
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
{triggerTooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{menuTrigger}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{triggerTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
menuTrigger
|
||||
)}
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="py-2 text-base">
|
||||
Re-run via API
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { StrictMode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
|
|
@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => {
|
|||
clipboardPasteFrom: ReturnType<typeof vi.fn>;
|
||||
sendKey: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
_framebufferUpdate: () => boolean;
|
||||
}> = [];
|
||||
const apiGet = vi.fn(async () => ({
|
||||
data: {
|
||||
|
|
@ -33,6 +35,7 @@ const mocks = vi.hoisted(() => {
|
|||
clipboardPasteFrom = vi.fn();
|
||||
sendKey = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
_framebufferUpdate = vi.fn(() => true);
|
||||
|
||||
private listeners: Record<string, RfbListener[]> = {};
|
||||
|
||||
|
|
@ -78,10 +81,14 @@ const mocks = vi.hoisted(() => {
|
|||
|
||||
const recordingStore = {
|
||||
add: vi.fn(),
|
||||
addScreenshot: vi.fn(),
|
||||
applyInterpretationUpdate: vi.fn(),
|
||||
compressedChunks: [],
|
||||
draftEditDepth: 0,
|
||||
getEventCount: vi.fn(() => 0),
|
||||
getSecondsRecording: vi.fn(() => 0),
|
||||
isRecording: false,
|
||||
manualCapturePaused: false,
|
||||
pendingEvents: [],
|
||||
reset: vi.fn(),
|
||||
setIsRecording: vi.fn(),
|
||||
|
|
@ -118,11 +125,24 @@ vi.mock("@/store/SettingsStore", () => ({
|
|||
useSettingsStore: () => mocks.settingsStore,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/useRecordingStore", () => ({
|
||||
useRecordingStore: () => mocks.recordingStore,
|
||||
}));
|
||||
vi.mock("@/store/useRecordingStore", () => {
|
||||
// Honor the selector: BrowserStream reads slices (e.g. state.isRecording) via
|
||||
// useRecordingStore(selector). Ignoring the selector and returning the whole
|
||||
// store makes primitive selectors yield the store object instead of the field
|
||||
// value — truthy where a boolean was expected — spuriously rendering the
|
||||
// recording UI.
|
||||
const useRecordingStore = (
|
||||
selector?: (state: typeof mocks.recordingStore) => unknown,
|
||||
) => (selector ? selector(mocks.recordingStore) : mocks.recordingStore);
|
||||
// Also read imperatively (reset/addScreenshot/etc.) via getState().
|
||||
useRecordingStore.getState = () => mocks.recordingStore;
|
||||
return {
|
||||
useRecordingStore,
|
||||
countVisibleDraftSteps: (steps: Array<unknown> = []) => steps.length,
|
||||
};
|
||||
});
|
||||
|
||||
function renderBrowserStream() {
|
||||
function renderBrowserStream(props: { onActivity?: () => void } = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
|
@ -137,11 +157,32 @@ function renderBrowserStream() {
|
|||
browserSessionId="pbs_test"
|
||||
interactive={false}
|
||||
showControlButtons={true}
|
||||
onActivity={props.onActivity}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithRecordingReset(
|
||||
resetRecordingOnUnmount: boolean | undefined,
|
||||
{ strict = false }: { strict?: boolean } = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const tree = (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserStream
|
||||
browserSessionId="pbs_test"
|
||||
interactive={false}
|
||||
showControlButtons={true}
|
||||
resetRecordingOnUnmount={resetRecordingOnUnmount}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return render(strict ? <StrictMode>{tree}</StrictMode> : tree);
|
||||
}
|
||||
|
||||
describe("BrowserStream", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
|
|
@ -191,4 +232,58 @@ describe("BrowserStream", () => {
|
|||
expect(mocks.rfbInstances[0]?.sendKey).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies activity after a VNC framebuffer update completes", async () => {
|
||||
const onActivity = vi.fn();
|
||||
|
||||
renderBrowserStream({ onActivity });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.rfbInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
mocks.rfbInstances[0]!._framebufferUpdate();
|
||||
|
||||
expect(onActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("recording reset lifecycle", () => {
|
||||
it("resets the recording store on unmount by default", async () => {
|
||||
const { unmount } = renderWithRecordingReset(undefined);
|
||||
await waitFor(() => expect(mocks.rfbInstances).toHaveLength(1));
|
||||
|
||||
expect(mocks.recordingStore.reset).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(mocks.recordingStore.reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reset the recording store on unmount when opted out", async () => {
|
||||
const { unmount } = renderWithRecordingReset(false);
|
||||
await waitFor(() => expect(mocks.rfbInstances).toHaveLength(1));
|
||||
|
||||
unmount();
|
||||
expect(mocks.recordingStore.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Pins that StrictMode's transient unmount really fires the cleanup in this
|
||||
// environment, so the opt-out test below cannot pass vacuously.
|
||||
it("runs the unmount cleanup on a StrictMode double-mount by default", async () => {
|
||||
renderWithRecordingReset(undefined, { strict: true });
|
||||
await waitFor(() =>
|
||||
expect(mocks.recordingStore.reset).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
});
|
||||
|
||||
// The local repro: StrictMode remounts the fresh VNC stream (mount -> unmount
|
||||
// -> mount) the instant recording starts. The transient unmount must not
|
||||
// clear the recording that just began.
|
||||
it("survives a StrictMode double-mount when opted out", async () => {
|
||||
renderWithRecordingReset(false, { strict: true });
|
||||
await waitFor(() =>
|
||||
expect(mocks.rfbInstances.length).toBeGreaterThanOrEqual(1),
|
||||
);
|
||||
|
||||
expect(mocks.recordingStore.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import _RFB, { type RfbEvent } from "@novnc/novnc/lib/rfb.js";
|
|||
type RFB = _RFB;
|
||||
const RFB = (_RFB as typeof _RFB & { default?: typeof _RFB }).default ?? _RFB;
|
||||
import { ExitIcon, HandIcon, InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import {
|
||||
|
|
@ -16,29 +17,24 @@ import {
|
|||
} from "@/api/types";
|
||||
import { Tip } from "@/components/Tip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useRecordingElapsedSeconds } from "@/hooks/useRecordingElapsedSeconds";
|
||||
import { statusIsNotFinalized } from "@/routes/tasks/types";
|
||||
import { buildOptimisticStep } from "@/routes/workflows/editor/recording/optimisticSteps";
|
||||
import { useClientIdStore } from "@/store/useClientIdStore";
|
||||
import {
|
||||
useRecordingStore,
|
||||
countVisibleDraftSteps,
|
||||
type ExfiltratedEventConsoleParams,
|
||||
type MessageInExfiltratedEvent,
|
||||
type RecordingInterpretationUpdate,
|
||||
} from "@/store/useRecordingStore";
|
||||
import { useSettingsStore } from "@/store/SettingsStore";
|
||||
import { wssBaseUrl, newWssBaseUrl, getCredentialParam } from "@/util/env";
|
||||
import { copyText } from "@/util/copyText";
|
||||
import { formatRecordingClock } from "@/util/recordingClock";
|
||||
import { cn } from "@/util/utils";
|
||||
import { captureRecordBrowser } from "@/util/recordBrowserTelemetry";
|
||||
import {
|
||||
StreamStatusPanel,
|
||||
type StreamDiagnostic,
|
||||
|
|
@ -66,6 +62,14 @@ interface BrowserSession {
|
|||
|
||||
interface CommandBeginExfiltration {
|
||||
kind: "begin-exfiltration";
|
||||
workflow_permanent_id?: string;
|
||||
live_interpretation_enabled?: boolean;
|
||||
// Declares that this client understands delta interpretation updates, so the
|
||||
// backend may send changed_steps instead of full snapshots.
|
||||
supports_interpretation_deltas?: boolean;
|
||||
// Per-recording id: stable across reconnects, new per recording, so the
|
||||
// backend reuses the session on reconnect but starts fresh on a new recording.
|
||||
recording_attempt_id?: string;
|
||||
}
|
||||
|
||||
interface CommandCedeControl {
|
||||
|
|
@ -80,17 +84,33 @@ interface CommandTakeControl {
|
|||
kind: "take-control";
|
||||
}
|
||||
|
||||
interface CommandRecordingCapturePause {
|
||||
kind: "recording-capture-pause";
|
||||
}
|
||||
|
||||
interface CommandRecordingCaptureResume {
|
||||
kind: "recording-capture-resume";
|
||||
}
|
||||
|
||||
interface CommandRecordingRearmCapture {
|
||||
kind: "recording-rearm-capture";
|
||||
}
|
||||
|
||||
// a "Command" is an fire-n-forget out-message - it does not require a response
|
||||
type Command =
|
||||
| CommandBeginExfiltration
|
||||
| CommandCedeControl
|
||||
| CommandEndExfiltration
|
||||
| CommandRecordingCapturePause
|
||||
| CommandRecordingCaptureResume
|
||||
| CommandRecordingRearmCapture
|
||||
| CommandTakeControl;
|
||||
|
||||
const messageInKinds = [
|
||||
"ask-for-clipboard",
|
||||
"copied-text",
|
||||
"exfiltrated-event",
|
||||
"recording-interpretation-update",
|
||||
] as const;
|
||||
|
||||
type MessageInKind = (typeof messageInKinds)[number];
|
||||
|
|
@ -104,10 +124,15 @@ interface MessageInCopiedText {
|
|||
text: string;
|
||||
}
|
||||
|
||||
interface MessageInRecordingInterpretationUpdate extends RecordingInterpretationUpdate {
|
||||
kind: "recording-interpretation-update";
|
||||
}
|
||||
|
||||
type MessageIn =
|
||||
| MessageInCopiedText
|
||||
| MessageInAskForClipboard
|
||||
| MessageInExfiltratedEvent;
|
||||
| MessageInExfiltratedEvent
|
||||
| MessageInRecordingInterpretationUpdate;
|
||||
|
||||
interface MessageOutAskForClipboardResponse {
|
||||
kind: "ask-for-clipboard-response";
|
||||
|
|
@ -121,6 +146,9 @@ type Props = {
|
|||
exfiltrate?: boolean;
|
||||
interactive?: boolean;
|
||||
showControlButtons?: boolean;
|
||||
// Whether unmounting clears the recording store. The studio passes false: it
|
||||
// remounts this component across CDP<->VNC swaps without the session ending.
|
||||
resetRecordingOnUnmount?: boolean;
|
||||
task?: {
|
||||
run: TaskApiResponse;
|
||||
};
|
||||
|
|
@ -130,22 +158,110 @@ type Props = {
|
|||
resizeTrigger?: number;
|
||||
isVisible?: boolean;
|
||||
isExecuting?: boolean;
|
||||
// Hide the REC pill overlay when the recording panel is visible beside the
|
||||
// stream (its header already shows the timer + step count).
|
||||
hideRecordingIndicator?: boolean;
|
||||
onReadyChange?: (isReady: boolean, browserSessionId: string | null) => void;
|
||||
onActivity?: () => void;
|
||||
// --
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
type RfbWithFrameUpdates = RFB & {
|
||||
_framebufferUpdate?: () => boolean;
|
||||
};
|
||||
|
||||
/** VNC encode settings: favor fast frames when the user is driving the browser. */
|
||||
function applyVncStreamProfile(
|
||||
rfb: RFB,
|
||||
profile: "interactive" | "passive",
|
||||
): void {
|
||||
if (profile === "interactive") {
|
||||
// Low CPU per frame beats max zlib compression for click/type latency.
|
||||
rfb.compressionLevel = 1;
|
||||
rfb.qualityLevel = 7;
|
||||
return;
|
||||
}
|
||||
rfb.compressionLevel = 2;
|
||||
rfb.qualityLevel = 6;
|
||||
}
|
||||
|
||||
function RecordingPill() {
|
||||
const {
|
||||
finishRequested,
|
||||
manualCapturePaused,
|
||||
draftSteps,
|
||||
deletedStepIds,
|
||||
exposedEventCount,
|
||||
optimisticStepCount,
|
||||
interpretationEnabled,
|
||||
} = useRecordingStore(
|
||||
useShallow((state) => ({
|
||||
finishRequested: state.finishRequested,
|
||||
manualCapturePaused: state.manualCapturePaused,
|
||||
draftSteps: state.draftSteps,
|
||||
deletedStepIds: state.deletedStepIds,
|
||||
exposedEventCount: state.exposedEventCount,
|
||||
optimisticStepCount: state.optimisticSteps.length,
|
||||
interpretationEnabled: state.workflowPermanentId !== null,
|
||||
})),
|
||||
);
|
||||
|
||||
const interpretedStepCount = useMemo(
|
||||
() => countVisibleDraftSteps(draftSteps, deletedStepIds),
|
||||
[draftSteps, deletedStepIds],
|
||||
);
|
||||
const elapsedSeconds = useRecordingElapsedSeconds();
|
||||
// Show interpreted + optimistic steps whenever interpretation is enabled, not
|
||||
// just after the first snapshot arrives — otherwise the first step waits a
|
||||
// backend round-trip even though the optimistic placeholder is already local.
|
||||
const count = interpretationEnabled
|
||||
? interpretedStepCount + optimisticStepCount
|
||||
: exposedEventCount;
|
||||
|
||||
const paused = manualCapturePaused && !finishRequested;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center gap-2 rounded-full border px-3 text-xs font-semibold tabular-nums",
|
||||
paused
|
||||
? "border-amber-500/50 bg-amber-950 text-amber-200"
|
||||
: "border-red-500/50 bg-red-950 text-red-200",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full", {
|
||||
"bg-amber-500": paused,
|
||||
"bg-red-500": !paused,
|
||||
"animate-pulse": !finishRequested && !paused,
|
||||
"opacity-50": finishRequested,
|
||||
})}
|
||||
/>
|
||||
{finishRequested ? "FINISHING" : paused ? "PAUSED" : "REC"}{" "}
|
||||
{formatRecordingClock(elapsedSeconds)}
|
||||
<span className={paused ? "text-amber-400/80" : "text-red-400/80"}>
|
||||
·
|
||||
</span>
|
||||
{count}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserStream({
|
||||
browserSessionId = undefined,
|
||||
exfiltrate = false,
|
||||
interactive = true,
|
||||
showControlButtons = undefined,
|
||||
resetRecordingOnUnmount = true,
|
||||
task = undefined,
|
||||
workflow = undefined,
|
||||
resizeTrigger,
|
||||
isVisible = true,
|
||||
isExecuting = false,
|
||||
hideRecordingIndicator = false,
|
||||
onReadyChange,
|
||||
onActivity,
|
||||
// --
|
||||
onClose,
|
||||
}: Props) {
|
||||
|
|
@ -226,6 +342,7 @@ function BrowserStream({
|
|||
setCanvasContainer(node);
|
||||
}, []);
|
||||
const rfbRef = useRef<RFB | null>(null);
|
||||
const onActivityRef = useRef(onActivity);
|
||||
const userCanSendVncInputRef = useRef(false);
|
||||
const observerRef = useRef<MutationObserver | null>(null);
|
||||
const messageReconnectAttemptsRef = useRef(0);
|
||||
|
|
@ -233,7 +350,7 @@ function BrowserStream({
|
|||
null,
|
||||
);
|
||||
const clientId = useClientIdStore((state) => state.clientId);
|
||||
const recordingStore = useRecordingStore();
|
||||
const isRecording = useRecordingStore((state) => state.isRecording);
|
||||
const settingsStore = useSettingsStore();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const isBrowserSessionAvailable =
|
||||
|
|
@ -241,6 +358,10 @@ function BrowserStream({
|
|||
const isBrowserSessionBackendReady =
|
||||
entity !== "browserSession" || isBrowserSessionStarted;
|
||||
|
||||
useEffect(() => {
|
||||
onActivityRef.current = onActivity;
|
||||
}, [onActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsBrowserSessionStarted(false);
|
||||
setIsReady(false);
|
||||
|
|
@ -364,6 +485,11 @@ function BrowserStream({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// The low-latency encode profile is scoped to recording: that's where frame
|
||||
// lag directly delays draft feedback. Other interactive live-browser streams
|
||||
// keep the default profile to avoid a broad bandwidth/CPU bump.
|
||||
const vncInteractive = exfiltrate;
|
||||
|
||||
// vnc socket
|
||||
useEffect(
|
||||
() => {
|
||||
|
|
@ -431,6 +557,23 @@ function BrowserStream({
|
|||
const rfb = new RFB(canvas, vncUrl);
|
||||
|
||||
rfb.scaleViewport = true;
|
||||
applyVncStreamProfile(rfb, vncInteractive ? "interactive" : "passive");
|
||||
|
||||
const frameUpdateRfb = rfb as RfbWithFrameUpdates;
|
||||
// noVNC does not expose a public framebuffer-update event in 1.5.x.
|
||||
// Hook the internal method defensively so activity tracking degrades
|
||||
// to no-op if the private API changes.
|
||||
const originalFrameUpdate =
|
||||
frameUpdateRfb._framebufferUpdate?.bind(rfb);
|
||||
if (originalFrameUpdate) {
|
||||
frameUpdateRfb._framebufferUpdate = () => {
|
||||
const didCompleteFrameUpdate = originalFrameUpdate();
|
||||
if (didCompleteFrameUpdate) {
|
||||
onActivityRef.current?.();
|
||||
}
|
||||
return didCompleteFrameUpdate;
|
||||
};
|
||||
}
|
||||
|
||||
rfbRef.current = rfb;
|
||||
|
||||
|
|
@ -500,6 +643,17 @@ function BrowserStream({
|
|||
],
|
||||
);
|
||||
|
||||
// Re-apply encode profile when recording starts without tearing down the socket.
|
||||
useEffect(() => {
|
||||
if (!rfbRef.current) {
|
||||
return;
|
||||
}
|
||||
applyVncStreamProfile(
|
||||
rfbRef.current,
|
||||
vncInteractive ? "interactive" : "passive",
|
||||
);
|
||||
}, [vncInteractive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStream || !canvasContainer || !runId) {
|
||||
return;
|
||||
|
|
@ -674,6 +828,13 @@ function BrowserStream({
|
|||
}
|
||||
}, [task, workflow]);
|
||||
|
||||
const { workflowPermanentId, recordingAttemptId } = useRecordingStore(
|
||||
useShallow((state) => ({
|
||||
workflowPermanentId: state.workflowPermanentId,
|
||||
recordingAttemptId: state.recordingAttemptId,
|
||||
})),
|
||||
);
|
||||
|
||||
// effect for exfiltration
|
||||
useEffect(() => {
|
||||
const sendCommand = (command: Command) => {
|
||||
|
|
@ -684,10 +845,56 @@ function BrowserStream({
|
|||
messageSocket.send(JSON.stringify(command));
|
||||
};
|
||||
|
||||
sendCommand({
|
||||
kind: exfiltrate ? "begin-exfiltration" : "end-exfiltration",
|
||||
});
|
||||
}, [exfiltrate, messageSocket]);
|
||||
if (exfiltrate) {
|
||||
// Including a workflow id turns on backend live interpretation: the
|
||||
// server streams recording-interpretation-update draft step snapshots
|
||||
// back over this same socket while the user records.
|
||||
sendCommand({
|
||||
kind: "begin-exfiltration",
|
||||
workflow_permanent_id: workflowPermanentId ?? undefined,
|
||||
live_interpretation_enabled: Boolean(workflowPermanentId),
|
||||
// Client capability: the backend only emits per-step delta updates
|
||||
// (changed_steps upserted by step_id) to clients that declare support;
|
||||
// otherwise it falls back to full snapshots.
|
||||
supports_interpretation_deltas: true,
|
||||
// Stable across reconnects of this recording; a new recording mints a
|
||||
// new id so the backend starts a fresh interpretation session.
|
||||
recording_attempt_id: recordingAttemptId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
sendCommand({ kind: "end-exfiltration" });
|
||||
}
|
||||
}, [exfiltrate, messageSocket, workflowPermanentId, recordingAttemptId]);
|
||||
|
||||
const manualCapturePaused = useRecordingStore(
|
||||
(state) => state.manualCapturePaused,
|
||||
);
|
||||
const draftEditDepth = useRecordingStore((state) => state.draftEditDepth);
|
||||
const capturePaused = manualCapturePaused || draftEditDepth > 0;
|
||||
const previousCapturePausedRef = useRef(false);
|
||||
|
||||
// Pause exfiltration + live interpretation while the operator edits drafts
|
||||
// or explicitly pauses capture.
|
||||
useEffect(() => {
|
||||
if (!exfiltrate || !messageSocket) {
|
||||
// Backend pause state is per exfiltration session, so start the next
|
||||
// session's edge detection from "not paused". A recording that ended
|
||||
// while paused would otherwise fire a spurious resume on the next start;
|
||||
// and if capture IS paused on a mid-recording reconnect, this re-sends
|
||||
// the pause to the new socket (idempotent) instead of assuming it.
|
||||
previousCapturePausedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const wasPaused = previousCapturePausedRef.current;
|
||||
if (!wasPaused && capturePaused) {
|
||||
messageSocket.send(JSON.stringify({ kind: "recording-capture-pause" }));
|
||||
} else if (wasPaused && !capturePaused) {
|
||||
messageSocket.send(JSON.stringify({ kind: "recording-capture-resume" }));
|
||||
}
|
||||
|
||||
previousCapturePausedRef.current = capturePaused;
|
||||
}, [capturePaused, exfiltrate, messageSocket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive) {
|
||||
|
|
@ -695,6 +902,15 @@ function BrowserStream({
|
|||
}
|
||||
}, [interactive]);
|
||||
|
||||
// When control can no longer be offered (buttons hidden and not inherently
|
||||
// interactive), a prior grab must be released or its input keeps flowing.
|
||||
// Recording is exempt: it holds take-control for exfiltration.
|
||||
useEffect(() => {
|
||||
if (!interactive && !showControlButtons && !isRecording) {
|
||||
setUserIsControlling(false);
|
||||
}
|
||||
}, [interactive, showControlButtons, isRecording]);
|
||||
|
||||
const theUserIsControlling =
|
||||
userIsControlling || (interactive && !showControlButtons);
|
||||
|
||||
|
|
@ -721,18 +937,26 @@ function BrowserStream({
|
|||
};
|
||||
}, [canvasContainer]);
|
||||
|
||||
// effect to ensure the recordingStore is reset when the component unmounts
|
||||
// Read the flag through a ref so the unmount cleanup stays mount-scoped: a
|
||||
// StrictMode double-mount or transport swap must not cancel a live recording.
|
||||
const resetRecordingOnUnmountRef = useRef(resetRecordingOnUnmount);
|
||||
resetRecordingOnUnmountRef.current = resetRecordingOnUnmount;
|
||||
// By default an unmount means the user abandoned the session, and the reset
|
||||
// keeps stale isRecording from leaking into the next mounted workflow as a
|
||||
// stuck recording panel. Surfaces that remount the stream while the session
|
||||
// lives on (transport swaps, per-run streams) opt out and reset at the
|
||||
// session level instead.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recordingStore.reset();
|
||||
if (resetRecordingOnUnmountRef.current) {
|
||||
useRecordingStore.getState().reset();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// effect to ensure 'take-control' is sent on the rising edge of
|
||||
// recordingStore.isRecording
|
||||
// effect to ensure 'take-control' is sent on the rising edge of isRecording
|
||||
useEffect(() => {
|
||||
if (!recordingStore.isRecording) {
|
||||
if (!isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -750,7 +974,7 @@ function BrowserStream({
|
|||
|
||||
sendCommand({ kind: "take-control" });
|
||||
setUserIsControlling(true);
|
||||
}, [recordingStore.isRecording, isMessageConnected, messageSocket]);
|
||||
}, [isRecording, isMessageConnected, messageSocket]);
|
||||
|
||||
/**
|
||||
* TODO(jdo): could use zod or smth similar
|
||||
|
|
@ -815,6 +1039,38 @@ function BrowserStream({
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "recording-interpretation-update": {
|
||||
// steps is optional: a delta update carries changed_steps instead. Only
|
||||
// session_revision is required to accept the message.
|
||||
if (
|
||||
"session_revision" in data &&
|
||||
typeof data.session_revision === "number"
|
||||
) {
|
||||
const update = data as MessageInRecordingInterpretationUpdate;
|
||||
return {
|
||||
kind: "recording-interpretation-update",
|
||||
interpretation_session_id:
|
||||
typeof update.interpretation_session_id === "string"
|
||||
? update.interpretation_session_id
|
||||
: "",
|
||||
session_revision: update.session_revision,
|
||||
steps: Array.isArray(update.steps) ? update.steps : [],
|
||||
changed_steps: Array.isArray(update.changed_steps)
|
||||
? update.changed_steps
|
||||
: [],
|
||||
// Absent/true => full snapshot (legacy). Only false triggers delta merge.
|
||||
is_snapshot:
|
||||
typeof update.is_snapshot === "boolean"
|
||||
? update.is_snapshot
|
||||
: true,
|
||||
pending:
|
||||
typeof update.pending === "boolean" ? update.pending : false,
|
||||
finalized:
|
||||
typeof update.finalized === "boolean" ? update.finalized : false,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = kind;
|
||||
return _exhaustive;
|
||||
|
|
@ -822,6 +1078,34 @@ function BrowserStream({
|
|||
}
|
||||
};
|
||||
|
||||
// Best-effort frame grab from the VNC canvas at the moment of a recorded
|
||||
// click, so the live feed can show a zoomed shot at the action point.
|
||||
const captureRecordingScreenshot = (
|
||||
params: ExfiltratedEventConsoleParams,
|
||||
) => {
|
||||
const schedule =
|
||||
typeof requestIdleCallback === "function"
|
||||
? (fn: () => void) => requestIdleCallback(fn, { timeout: 750 })
|
||||
: (fn: () => void) => window.setTimeout(fn, 0);
|
||||
|
||||
schedule(() => {
|
||||
try {
|
||||
const canvas = canvasContainer?.querySelector("canvas");
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
useRecordingStore.getState().addScreenshot({
|
||||
timestampMs: params.timestamp,
|
||||
dataUrl: canvas.toDataURL("image/jpeg", 0.5),
|
||||
xp: params.mousePosition.xp,
|
||||
yp: params.mousePosition.yp,
|
||||
});
|
||||
} catch {
|
||||
// toDataURL can throw on a tainted/headless canvas; shots are optional
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown, ws: WebSocket | null) => {
|
||||
const message = getMessage(data);
|
||||
|
||||
|
|
@ -902,7 +1186,52 @@ function BrowserStream({
|
|||
break;
|
||||
}
|
||||
case "exfiltrated-event": {
|
||||
recordingStore.add(message);
|
||||
// Read store state fresh: this handler is attached once per socket
|
||||
// and would otherwise see a stale isRecording.
|
||||
const store = useRecordingStore.getState();
|
||||
if (!store.isRecording && !store.finishRequested) {
|
||||
break;
|
||||
}
|
||||
if (store.isCapturePaused()) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
store.isRecording &&
|
||||
message.source === "console" &&
|
||||
message.params.type === "click"
|
||||
) {
|
||||
captureRecordingScreenshot(message.params);
|
||||
}
|
||||
if (
|
||||
store.isRecording &&
|
||||
!store.finishRequested &&
|
||||
message.source === "cdp" &&
|
||||
(message.event_name === "nav:frame_navigated" ||
|
||||
message.event_name === "nav:navigated_within_document")
|
||||
) {
|
||||
if (ws) {
|
||||
ws.send(JSON.stringify({ kind: "recording-rearm-capture" }));
|
||||
}
|
||||
}
|
||||
if (store.isRecording && !store.finishRequested) {
|
||||
const optimistic = buildOptimisticStep(message);
|
||||
if (optimistic) {
|
||||
store.addOptimisticStep(optimistic);
|
||||
}
|
||||
}
|
||||
store.add(message);
|
||||
break;
|
||||
}
|
||||
case "recording-interpretation-update": {
|
||||
useRecordingStore.getState().applyInterpretationUpdate({
|
||||
interpretation_session_id: message.interpretation_session_id,
|
||||
session_revision: message.session_revision,
|
||||
steps: message.steps,
|
||||
changed_steps: message.changed_steps,
|
||||
is_snapshot: message.is_snapshot,
|
||||
pending: message.pending,
|
||||
finalized: message.finalized,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
|
@ -998,84 +1327,25 @@ function BrowserStream({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{recordingStore.isRecording && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute flex aspect-video w-full items-center justify-center rounded-xl p-2 outline outline-8 outline-offset-[-2px] outline-red-500 animate-in fade-in">
|
||||
{isRecording && (
|
||||
<div className="pointer-events-none absolute flex aspect-video w-full items-center justify-center rounded-xl p-2 outline outline-8 outline-offset-[-2px] outline-red-500 animate-in fade-in">
|
||||
{/* The pill duplicates the recording panel's header (timer + step
|
||||
count), so it's hidden while the panel is visible alongside. */}
|
||||
{!hideRecordingIndicator && (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="pointer-events-auto absolute top-[-3rem] flex w-full items-center justify-start gap-2 text-red-500">
|
||||
<div className="truncate">Browser is recording</div>
|
||||
<Tip content="To finish and turn your actions into blocks, click the stop icon.">
|
||||
<div className="cursor-pointer">
|
||||
<div className="pointer-events-auto absolute top-[-3rem] flex w-full items-center justify-start gap-2">
|
||||
<RecordingPill />
|
||||
<Tip content="Your actions appear as blocks in the recording panel. Finish with Done, or use the trash icon to discard.">
|
||||
<div className="cursor-pointer text-red-500">
|
||||
<InfoCircledIcon />
|
||||
</div>
|
||||
</Tip>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="ml-auto cursor-pointer"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
style={{
|
||||
marginTop: "-0.5rem",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const hasEvents =
|
||||
recordingStore.pendingEvents.length > 0 ||
|
||||
recordingStore.compressedChunks.length > 0;
|
||||
if (!hasEvents) {
|
||||
e.preventDefault();
|
||||
captureRecordBrowser("record_browser.cancelled", {
|
||||
event_count_at_cancel:
|
||||
recordingStore.getEventCount(),
|
||||
seconds_recording:
|
||||
recordingStore.getSecondsRecording(),
|
||||
});
|
||||
recordingStore.setIsRecording(false);
|
||||
recordingStore.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
cancel
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel recording?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have recorded events that will be lost if you
|
||||
cancel. Are you sure you want to cancel the recording?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Keep recording</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
captureRecordBrowser("record_browser.cancelled", {
|
||||
event_count_at_cancel:
|
||||
recordingStore.getEventCount(),
|
||||
seconds_recording:
|
||||
recordingStore.getSecondsRecording(),
|
||||
});
|
||||
recordingStore.setIsRecording(false);
|
||||
recordingStore.reset();
|
||||
}}
|
||||
>
|
||||
Cancel recording
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isExecuting && !recordingStore.isRecording && (
|
||||
{isExecuting && !isRecording && (
|
||||
<div className="pointer-events-none absolute flex aspect-video w-full animate-glow items-center justify-center rounded-xl p-2 outline outline-8 outline-offset-[-2px] outline-yellow-500">
|
||||
<div className="relative h-full w-full">
|
||||
<div className="pointer-events-auto absolute top-[-3rem] flex w-full items-center justify-start gap-2 text-yellow-500">
|
||||
|
|
|
|||
596
skyvern-frontend/src/components/CustomLLMConfigForm.tsx
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
CheckIcon,
|
||||
Cross2Icon,
|
||||
EyeClosedIcon,
|
||||
EyeOpenIcon,
|
||||
Pencil1Icon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { CustomLLM, CustomLLMConfig, CustomLLMProvider } from "@/api/types";
|
||||
import { useCustomLLMs } from "@/hooks/useCustomLLMs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const providerLabels: Record<CustomLLMProvider, string> = {
|
||||
openai_compatible: "OpenAI compatible",
|
||||
ollama: "Ollama",
|
||||
openrouter: "OpenRouter",
|
||||
};
|
||||
|
||||
const optionalString = z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim() || null : value),
|
||||
z.string().nullable().optional(),
|
||||
);
|
||||
|
||||
const optionalNumber = (schema: z.ZodNumber) =>
|
||||
z.preprocess((value) => {
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
return Number(value);
|
||||
}, schema.nullable().optional());
|
||||
|
||||
const configSchema = z
|
||||
.object({
|
||||
display_name: z.string().trim().min(1, "Name is required"),
|
||||
provider: z.enum(["openai_compatible", "ollama", "openrouter"]),
|
||||
model_name: z.string().trim().min(1, "Model ID is required"),
|
||||
api_base: optionalString,
|
||||
api_key: optionalString,
|
||||
api_version: optionalString,
|
||||
supports_vision: z.boolean(),
|
||||
add_assistant_prefix: z.boolean(),
|
||||
max_completion_tokens: optionalNumber(
|
||||
z
|
||||
.number()
|
||||
.int("Max tokens must be a whole number")
|
||||
.min(1, "Max tokens must be at least 1")
|
||||
.max(1_000_000, "Max tokens must be 1,000,000 or less"),
|
||||
),
|
||||
temperature: optionalNumber(
|
||||
z
|
||||
.number()
|
||||
.min(0, "Temperature must be at least 0")
|
||||
.max(2, "Temperature must be 2 or less"),
|
||||
),
|
||||
reasoning_effort: optionalString,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.provider === "openai_compatible" && !value.api_base) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["api_base"],
|
||||
message: "API base is required",
|
||||
});
|
||||
}
|
||||
if (
|
||||
(value.provider === "openai_compatible" ||
|
||||
value.provider === "openrouter") &&
|
||||
!value.api_key
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["api_key"],
|
||||
message: "API key is required",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const formSchema = z.object({ config: configSchema });
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const providerDefaults: Record<CustomLLMProvider, Partial<CustomLLMConfig>> = {
|
||||
openai_compatible: {
|
||||
api_base: "",
|
||||
api_key: "",
|
||||
api_version: "",
|
||||
supports_vision: true,
|
||||
add_assistant_prefix: false,
|
||||
},
|
||||
ollama: {
|
||||
api_base: "http://localhost:11434",
|
||||
api_key: "",
|
||||
api_version: "",
|
||||
supports_vision: false,
|
||||
add_assistant_prefix: false,
|
||||
},
|
||||
openrouter: {
|
||||
api_base: "https://openrouter.ai/api/v1",
|
||||
api_key: "",
|
||||
api_version: "",
|
||||
supports_vision: true,
|
||||
add_assistant_prefix: false,
|
||||
},
|
||||
};
|
||||
|
||||
function emptyFormValues(provider: CustomLLMProvider = "openai_compatible") {
|
||||
return {
|
||||
config: {
|
||||
display_name: "",
|
||||
provider,
|
||||
model_name: "",
|
||||
api_base: providerDefaults[provider].api_base ?? "",
|
||||
api_key: providerDefaults[provider].api_key ?? "",
|
||||
api_version: providerDefaults[provider].api_version ?? "",
|
||||
supports_vision: providerDefaults[provider].supports_vision ?? true,
|
||||
add_assistant_prefix:
|
||||
providerDefaults[provider].add_assistant_prefix ?? false,
|
||||
max_completion_tokens: null,
|
||||
temperature: null,
|
||||
reasoning_effort: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function valuesForCustomLLM(customLLM: CustomLLM): FormData {
|
||||
return {
|
||||
config: {
|
||||
...customLLM.config,
|
||||
api_base: customLLM.config.api_base ?? "",
|
||||
api_key: customLLM.config.api_key ?? "",
|
||||
api_version: customLLM.config.api_version ?? "",
|
||||
max_completion_tokens: customLLM.config.max_completion_tokens ?? null,
|
||||
temperature: customLLM.config.temperature ?? null,
|
||||
reasoning_effort: customLLM.config.reasoning_effort ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requestFromValues(values: FormData): { config: CustomLLMConfig } {
|
||||
return {
|
||||
config: {
|
||||
...values.config,
|
||||
api_base: values.config.api_base || null,
|
||||
api_key: values.config.api_key || null,
|
||||
api_version: values.config.api_version || null,
|
||||
max_completion_tokens: values.config.max_completion_tokens || null,
|
||||
temperature:
|
||||
values.config.temperature === undefined
|
||||
? null
|
||||
: values.config.temperature,
|
||||
reasoning_effort: values.config.reasoning_effort || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomLLMConfigForm() {
|
||||
const [editing, setEditing] = useState<CustomLLM | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const {
|
||||
customLLMs,
|
||||
isLoading,
|
||||
createCustomLLM,
|
||||
updateCustomLLM,
|
||||
deleteCustomLLM,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
} = useCustomLLMs();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: emptyFormValues(),
|
||||
});
|
||||
const provider = form.watch("config.provider");
|
||||
const isMutating = isCreating || isUpdating || isDeleting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
return;
|
||||
}
|
||||
form.reset(valuesForCustomLLM(editing));
|
||||
}, [editing, form]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setShowApiKey(false);
|
||||
form.reset(emptyFormValues());
|
||||
};
|
||||
|
||||
const onSubmit = (values: FormData) => {
|
||||
const request = requestFromValues(values);
|
||||
if (editing) {
|
||||
updateCustomLLM({ id: editing.id, request }, { onSuccess: resetForm });
|
||||
return;
|
||||
}
|
||||
createCustomLLM(request, { onSuccess: resetForm });
|
||||
};
|
||||
|
||||
const onProviderChange = (nextProvider: CustomLLMProvider) => {
|
||||
form.setValue("config.provider", nextProvider);
|
||||
form.setValue(
|
||||
"config.api_base",
|
||||
providerDefaults[nextProvider].api_base ?? "",
|
||||
);
|
||||
form.setValue(
|
||||
"config.api_key",
|
||||
providerDefaults[nextProvider].api_key ?? "",
|
||||
);
|
||||
form.setValue(
|
||||
"config.api_version",
|
||||
providerDefaults[nextProvider].api_version ?? "",
|
||||
);
|
||||
form.setValue(
|
||||
"config.supports_vision",
|
||||
providerDefaults[nextProvider].supports_vision ?? true,
|
||||
);
|
||||
form.setValue(
|
||||
"config.add_assistant_prefix",
|
||||
providerDefaults[nextProvider].add_assistant_prefix ?? false,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{customLLMs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{customLLMs.map((customLLM) => (
|
||||
<div
|
||||
key={customLLM.id}
|
||||
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{customLLM.config.display_name}
|
||||
</span>
|
||||
<span className="rounded border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{providerLabels[customLLM.config.provider]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{customLLM.config.model_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isMutating}
|
||||
onClick={() => setEditing(customLLM)}
|
||||
>
|
||||
<Pencil1Icon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isMutating}
|
||||
onClick={() => deleteCustomLLM(customLLM)}
|
||||
>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.display_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Local Llama"
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
onProviderChange(value as CustomLLMProvider)
|
||||
}
|
||||
disabled={isLoading || isMutating}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(providerLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={
|
||||
provider === "openrouter"
|
||||
? "anthropic/claude-3.5-sonnet"
|
||||
: provider === "ollama"
|
||||
? "llama3.1"
|
||||
: "mistral"
|
||||
}
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{provider === "ollama"
|
||||
? "Use the Ollama model name. Prefixes like ollama_chat/ are accepted."
|
||||
: "Use the provider model identifier without the LiteLLM prefix."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.api_base"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Base</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="https://api.example.com/v1"
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
API Key{provider === "ollama" ? " (optional)" : ""}
|
||||
</FormLabel>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type={showApiKey ? "text" : "password"}
|
||||
placeholder={
|
||||
provider === "ollama" ? "optional" : "sk-..."
|
||||
}
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowApiKey((value) => !value)}
|
||||
disabled={isLoading || isMutating}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeClosedIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOpenIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{provider === "openai_compatible" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.api_version"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Version</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="optional"
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.max_completion_tokens"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Tokens</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="default"
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.temperature"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Temperature</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step="0.1"
|
||||
placeholder="default"
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.reasoning_effort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reasoning Effort</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="optional"
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.supports_vision"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">
|
||||
Supports vision
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.add_assistant_prefix"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isLoading || isMutating}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">
|
||||
Assistant prefix
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={isLoading || isMutating}>
|
||||
{editing ? (
|
||||
<CheckIcon className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{editing
|
||||
? isUpdating
|
||||
? "Saving..."
|
||||
: "Save Custom LLM"
|
||||
: isCreating
|
||||
? "Adding..."
|
||||
: "Add Custom LLM"}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading || isMutating}
|
||||
onClick={resetForm}
|
||||
>
|
||||
<Cross2Icon className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { TriggerType } from "@/api/types";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CubeIcon,
|
||||
CursorArrowIcon,
|
||||
LightningBoltIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
|
|
@ -18,6 +19,10 @@ const triggerConfig: Record<
|
|||
icon: <CursorArrowIcon className="size-3.5 text-slate-400" />,
|
||||
label: "Manual",
|
||||
},
|
||||
[TriggerType.Mcp]: {
|
||||
icon: <CubeIcon className="size-3.5 text-emerald-400" />,
|
||||
label: "MCP",
|
||||
},
|
||||
[TriggerType.Api]: {
|
||||
icon: <LightningBoltIcon className="size-3.5 text-amber-400" />,
|
||||
label: "API",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ const triggerTypeDropdownItems: Array<TriggerTypeDropdownItem> = [
|
|||
label: "Manual",
|
||||
value: TriggerType.Manual,
|
||||
},
|
||||
{
|
||||
label: "MCP",
|
||||
value: TriggerType.Mcp,
|
||||
},
|
||||
{
|
||||
label: "API",
|
||||
value: TriggerType.Api,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ describe("CopilotCTAStep", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/workflows/wpid_x/studio", {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/agents/wpid_x/studio", {
|
||||
state: { copilotMessage: "fill out my form" },
|
||||
}),
|
||||
);
|
||||
|
|
@ -135,7 +135,7 @@ describe("CopilotCTAStep", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/workflows/wpid_x/build", {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/agents/wpid_x/build", {
|
||||
state: { copilotMessage: "fill out my form" },
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ describe("CredentialSetupPrompt", () => {
|
|||
const cta = screen.getByRole("link", {
|
||||
name: /set up credentials in the editor/i,
|
||||
});
|
||||
expect(cta.getAttribute("href")).toBe("/workflows/wpid_123/studio");
|
||||
expect(cta.getAttribute("href")).toBe("/agents/wpid_123/studio");
|
||||
});
|
||||
|
||||
it("routes the CTA to /build when the studio preview is off", () => {
|
||||
|
|
@ -60,7 +60,7 @@ describe("CredentialSetupPrompt", () => {
|
|||
const cta = screen.getByRole("link", {
|
||||
name: /set up credentials in the editor/i,
|
||||
});
|
||||
expect(cta.getAttribute("href")).toBe("/workflows/wpid_123/build");
|
||||
expect(cta.getAttribute("href")).toBe("/agents/wpid_123/build");
|
||||
});
|
||||
|
||||
it("fires credentialSetupShown once on mount with the block count", () => {
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ describe("FirstRunRecoveryGuidance", () => {
|
|||
/>,
|
||||
);
|
||||
fireEvent.click(getByTestId("recovery-path-edit_workflow"));
|
||||
expect(navigateMock).toHaveBeenCalledWith("/workflows/wpid_123/studio");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/agents/wpid_123/studio");
|
||||
});
|
||||
|
||||
it("navigates to /build when the studio preview is off (AC2 wiring)", () => {
|
||||
|
|
@ -137,7 +137,7 @@ describe("FirstRunRecoveryGuidance", () => {
|
|||
/>,
|
||||
);
|
||||
fireEvent.click(getByTestId("recovery-path-edit_workflow"));
|
||||
expect(navigateMock).toHaveBeenCalledWith("/workflows/wpid_123/build");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/agents/wpid_123/build");
|
||||
});
|
||||
|
||||
it("records chosen + opened outcome for an external path (AC3)", () => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ function FirstRunRecoveryGuidance({
|
|||
if (id === "edit_workflow") {
|
||||
return workflowPermanentId
|
||||
? workflowEditorPath(workflowPermanentId, studioEnabled)
|
||||
: "/workflows";
|
||||
: "/agents";
|
||||
}
|
||||
return "/credentials";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,89 +55,37 @@ function blockBetween(css: string, openSelector: string): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
const REQUIRED_TOKEN_CASES = [
|
||||
["--input", ":root"],
|
||||
["--ring", ":root"],
|
||||
["--input", ".dark"],
|
||||
["--ring", ".dark"],
|
||||
["--success", ":root"],
|
||||
["--success-foreground", ":root"],
|
||||
["--warning", ":root"],
|
||||
["--warning-foreground", ":root"],
|
||||
["--tertiary", ":root"],
|
||||
["--tertiary-foreground", ":root"],
|
||||
["--cta", ":root"],
|
||||
["--cta-hover", ":root"],
|
||||
["--cta-foreground", ":root"],
|
||||
["--success", ".dark"],
|
||||
["--warning", ".dark"],
|
||||
["--tertiary", ".dark"],
|
||||
["--tertiary-foreground", ".dark"],
|
||||
["--cta", ".dark"],
|
||||
["--cta-hover", ".dark"],
|
||||
["--cta-foreground", ".dark"],
|
||||
] as const;
|
||||
|
||||
describe.each(CSS_FILES)("%s defines DS token vars", (file) => {
|
||||
const css = load(file);
|
||||
const root = blockBetween(css, ":root");
|
||||
const dark = blockBetween(css, ".dark");
|
||||
|
||||
it("defines --input under :root", () => {
|
||||
expect(root).toMatch(/--input:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --ring under :root", () => {
|
||||
expect(root).toMatch(/--ring:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --input under .dark", () => {
|
||||
expect(dark).toMatch(/--input:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --ring under .dark", () => {
|
||||
expect(dark).toMatch(/--ring:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --success under :root", () => {
|
||||
expect(root).toMatch(/--success:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --success-foreground under :root", () => {
|
||||
expect(root).toMatch(/--success-foreground:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --warning under :root", () => {
|
||||
expect(root).toMatch(/--warning:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --warning-foreground under :root", () => {
|
||||
expect(root).toMatch(/--warning-foreground:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --tertiary under :root", () => {
|
||||
expect(root).toMatch(/--tertiary:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --tertiary-foreground under :root", () => {
|
||||
expect(root).toMatch(/--tertiary-foreground:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --cta under :root", () => {
|
||||
expect(root).toMatch(/--cta:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --cta-hover under :root", () => {
|
||||
expect(root).toMatch(/--cta-hover:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --cta-foreground under :root", () => {
|
||||
expect(root).toMatch(/--cta-foreground:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --success under .dark", () => {
|
||||
expect(dark).toMatch(/--success:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --warning under .dark", () => {
|
||||
expect(dark).toMatch(/--warning:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --tertiary under .dark", () => {
|
||||
expect(dark).toMatch(/--tertiary:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --tertiary-foreground under .dark", () => {
|
||||
expect(dark).toMatch(/--tertiary-foreground:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --cta under .dark", () => {
|
||||
expect(dark).toMatch(/--cta:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --cta-hover under .dark", () => {
|
||||
expect(dark).toMatch(/--cta-hover:\s*[^;]+;/);
|
||||
});
|
||||
|
||||
it("defines --cta-foreground under .dark", () => {
|
||||
expect(dark).toMatch(/--cta-foreground:\s*[^;]+;/);
|
||||
it.each(REQUIRED_TOKEN_CASES)("defines %s under %s", (token, selector) => {
|
||||
const block = selector === ":root" ? root : dark;
|
||||
expect(block).toMatch(new RegExp(`${token}:\\s*[^;]+;`));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -153,29 +101,19 @@ describe("shared semantic tokens are consistent across cloud/eval/src", () => {
|
|||
expect(new Set(values).size).toBe(1);
|
||||
}
|
||||
|
||||
it("light-mode --ring is the same value in all 3 entrypoints", () => {
|
||||
expectConsistentToken("--ring", ":root");
|
||||
});
|
||||
|
||||
it("dark-mode --ring is the same value in all 3 entrypoints", () => {
|
||||
expectConsistentToken("--ring", ".dark");
|
||||
});
|
||||
|
||||
it("light-mode --cta is the same value in all 3 entrypoints", () => {
|
||||
expectConsistentToken("--cta", ":root");
|
||||
});
|
||||
|
||||
it("light-mode --cta-hover is the same value in all 3 entrypoints", () => {
|
||||
expectConsistentToken("--cta-hover", ":root");
|
||||
});
|
||||
|
||||
it("dark-mode --cta is the same value in all 3 entrypoints", () => {
|
||||
expectConsistentToken("--cta", ".dark");
|
||||
});
|
||||
|
||||
it("dark-mode --cta-hover is the same value in all 3 entrypoints", () => {
|
||||
expectConsistentToken("--cta-hover", ".dark");
|
||||
});
|
||||
it.each([
|
||||
["light-mode --ring", "--ring", ":root"],
|
||||
["dark-mode --ring", "--ring", ".dark"],
|
||||
["light-mode --cta", "--cta", ":root"],
|
||||
["light-mode --cta-hover", "--cta-hover", ":root"],
|
||||
["dark-mode --cta", "--cta", ".dark"],
|
||||
["dark-mode --cta-hover", "--cta-hover", ".dark"],
|
||||
] as const)(
|
||||
"%s is the same value in all 3 entrypoints",
|
||||
(_, token, selector) => {
|
||||
expectConsistentToken(token, selector);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The primary CTA must track the DS --primary token, not an independent
|
||||
|
|
@ -191,27 +129,24 @@ describe("--cta tracks the DS --primary (SKY-10608)", () => {
|
|||
describe.each(CSS_FILES)("%s", (file) => {
|
||||
const css = load(file);
|
||||
|
||||
it("light-mode --cta equals --primary", () => {
|
||||
expect(tokenValue(css, ":root", "--cta")).toBe(
|
||||
tokenValue(css, ":root", "--primary"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dark-mode --cta equals --primary", () => {
|
||||
expect(tokenValue(css, ".dark", "--cta")).toBe(
|
||||
tokenValue(css, ".dark", "--primary"),
|
||||
);
|
||||
});
|
||||
|
||||
it("light-mode --cta-foreground equals --primary-foreground", () => {
|
||||
expect(tokenValue(css, ":root", "--cta-foreground")).toBe(
|
||||
tokenValue(css, ":root", "--primary-foreground"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dark-mode --cta-foreground equals --primary-foreground", () => {
|
||||
expect(tokenValue(css, ".dark", "--cta-foreground")).toBe(
|
||||
tokenValue(css, ".dark", "--primary-foreground"),
|
||||
it.each([
|
||||
["light-mode --cta equals --primary", ":root", "--cta", "--primary"],
|
||||
["dark-mode --cta equals --primary", ".dark", "--cta", "--primary"],
|
||||
[
|
||||
"light-mode --cta-foreground equals --primary-foreground",
|
||||
":root",
|
||||
"--cta-foreground",
|
||||
"--primary-foreground",
|
||||
],
|
||||
[
|
||||
"dark-mode --cta-foreground equals --primary-foreground",
|
||||
".dark",
|
||||
"--cta-foreground",
|
||||
"--primary-foreground",
|
||||
],
|
||||
] as const)("%s", (_, selector, ctaToken, primaryToken) => {
|
||||
expect(tokenValue(css, selector, ctaToken)).toBe(
|
||||
tokenValue(css, selector, primaryToken),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// @vitest-environment jsdom
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnabled } = vi.hoisted(() => ({ mockEnabled: vi.fn() }));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagEnabled: () => mockEnabled(),
|
||||
}));
|
||||
|
||||
import { useAnalyticsDashboardFlag } from "./useAnalyticsDashboardFlag";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("useAnalyticsDashboardFlag", () => {
|
||||
it("passes through the posthog flag value when mock analytics is off", () => {
|
||||
mockEnabled.mockReturnValue(false);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
mockEnabled.mockReturnValue(undefined);
|
||||
expect(
|
||||
renderHook(() => useAnalyticsDashboardFlag()).result.current,
|
||||
).toBeUndefined();
|
||||
|
||||
mockEnabled.mockReturnValue(true);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("forces true under VITE_MOCK_ANALYTICS=1, even when the posthog flag is false or undefined", () => {
|
||||
vi.stubEnv("VITE_MOCK_ANALYTICS", "1");
|
||||
|
||||
mockEnabled.mockReturnValue(false);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
mockEnabled.mockReturnValue(undefined);
|
||||
expect(renderHook(() => useAnalyticsDashboardFlag()).result.current).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
13
skyvern-frontend/src/hooks/useAnalyticsDashboardFlag.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useFeatureFlagEnabled } from "posthog-js/react";
|
||||
import { ANALYTICS_DASHBOARD_FLAG } from "@/util/featureFlags";
|
||||
|
||||
// VITE_MOCK_ANALYTICS fakes API responses (see cloud/dev/mockAnalyticsServer.ts)
|
||||
// but can't reach real PostHog, so every ANALYTICS_DASHBOARD-gated surface
|
||||
// would stay hidden in local dev without this override.
|
||||
export function useAnalyticsDashboardFlag(): boolean | undefined {
|
||||
const enabled = useFeatureFlagEnabled(ANALYTICS_DASHBOARD_FLAG);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_MOCK_ANALYTICS === "1") {
|
||||
return true;
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
122
skyvern-frontend/src/hooks/useCustomLLMs.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import {
|
||||
ClearOrganizationAuthTokenResponse,
|
||||
CustomLLM,
|
||||
CustomLLMCreateRequest,
|
||||
CustomLLMListResponse,
|
||||
CustomLLMResponse,
|
||||
CustomLLMUpdateRequest,
|
||||
} from "@/api/types";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useCredentialGetter } from "./useCredentialGetter";
|
||||
|
||||
const customLLMsQueryKey = ["customLLMs"] as const;
|
||||
const modelsQueryKey = ["models"] as const;
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
return (
|
||||
(error as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail ||
|
||||
(error as Error)?.message ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
export function useCustomLLMs() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const query = useQuery<CustomLLMListResponse>({
|
||||
queryKey: customLLMsQueryKey,
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client.get("/custom-llms").then((response) => response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: customLLMsQueryKey });
|
||||
queryClient.invalidateQueries({ queryKey: modelsQueryKey });
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (request: CustomLLMCreateRequest) => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client
|
||||
.post("/custom-llms", request)
|
||||
.then((response) => response.data as CustomLLMResponse);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast({ title: "Success", description: "Custom LLM added" });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: getErrorMessage(error, "Failed to add custom LLM"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
request,
|
||||
}: {
|
||||
id: string;
|
||||
request: CustomLLMUpdateRequest;
|
||||
}) => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client
|
||||
.put(`/custom-llms/${id}`, request)
|
||||
.then((response) => response.data as CustomLLMResponse);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast({ title: "Success", description: "Custom LLM updated" });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: getErrorMessage(error, "Failed to update custom LLM"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (customLLM: CustomLLM) => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client
|
||||
.delete(`/custom-llms/${customLLM.id}`)
|
||||
.then(
|
||||
(response) => response.data as ClearOrganizationAuthTokenResponse,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast({ title: "Success", description: "Custom LLM deleted" });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: getErrorMessage(error, "Failed to delete custom LLM"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
customLLMs: query.data?.custom_llms ?? [],
|
||||
isLoading: query.isLoading,
|
||||
createCustomLLM: createMutation.mutate,
|
||||
updateCustomLLM: updateMutation.mutate,
|
||||
deleteCustomLLM: deleteMutation.mutate,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GoogleOAuthCredential } from "@/api/types";
|
||||
import {
|
||||
getGoogleOAuthCredentialScopesGranted,
|
||||
getGoogleOAuthCredentialScopesRequested,
|
||||
matchesGoogleOAuthIntegrationScopes,
|
||||
normalizeGoogleOAuthScopes,
|
||||
safePostCredentialsInvalidate,
|
||||
} from "./useGoogleOAuthCredentials";
|
||||
|
||||
const baseCredential: GoogleOAuthCredential = {
|
||||
|
|
@ -83,3 +84,33 @@ describe("Google OAuth credential scope helpers", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safePostCredentialsInvalidate", () => {
|
||||
it("posts on a live channel", () => {
|
||||
const postMessage = vi.fn();
|
||||
safePostCredentialsInvalidate({ postMessage });
|
||||
expect(postMessage).toHaveBeenCalledWith("invalidate");
|
||||
});
|
||||
|
||||
it("no-ops when the channel is null", () => {
|
||||
expect(() => safePostCredentialsInvalidate(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("swallows errors from a disposed channel", () => {
|
||||
const postMessage = vi.fn(() => {
|
||||
throw new DOMException("channel is closed", "InvalidStateError");
|
||||
});
|
||||
expect(() => safePostCredentialsInvalidate({ postMessage })).not.toThrow();
|
||||
expect(postMessage).toHaveBeenCalledWith("invalidate");
|
||||
});
|
||||
|
||||
it("rethrows unexpected postMessage errors", () => {
|
||||
const postMessage = vi.fn(() => {
|
||||
throw new Error("unexpected broadcast failure");
|
||||
});
|
||||
|
||||
expect(() => safePostCredentialsInvalidate({ postMessage })).toThrow(
|
||||
"unexpected broadcast failure",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,8 +21,25 @@ const credentialBroadcastChannel: BroadcastChannel | null =
|
|||
? new BroadcastChannel(BROADCAST_CHANNEL_NAME)
|
||||
: null;
|
||||
|
||||
// The channel can be disposed by the runtime (e.g. an embedded webview tearing
|
||||
// down its native bridge during auth navigation) before this fires, in which
|
||||
// case postMessage throws InvalidStateError. Cross-tab invalidation is
|
||||
// best-effort, so swallow the failure rather than surfacing it.
|
||||
export function safePostCredentialsInvalidate(
|
||||
channel: Pick<BroadcastChannel, "postMessage"> | null,
|
||||
): void {
|
||||
try {
|
||||
channel?.postMessage("invalidate");
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "InvalidStateError") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastCredentialsChanged() {
|
||||
credentialBroadcastChannel?.postMessage("invalidate");
|
||||
safePostCredentialsInvalidate(credentialBroadcastChannel);
|
||||
}
|
||||
|
||||
export function normalizeGoogleOAuthScopes(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,13 @@ export function useNotificationStream() {
|
|||
// Flatten authorization query building
|
||||
let authQuery = "";
|
||||
if (credentialGetter) {
|
||||
authQuery = `?token=Bearer ${await credentialGetter()}`;
|
||||
// A null token (transient Clerk failure) must not become "Bearer null":
|
||||
// the server closes it 1002, which latches authFailedRef and stops all
|
||||
// reconnects. Skip this cycle instead and reconnect on the next trigger.
|
||||
const token = await credentialGetter();
|
||||
if (token) {
|
||||
authQuery = `?token=Bearer ${token}`;
|
||||
}
|
||||
} else if (getRuntimeApiKey()) {
|
||||
authQuery = `?apikey=${getRuntimeApiKey()}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
|
||||
import { useRecordingElapsedSeconds } from "./useRecordingElapsedSeconds";
|
||||
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
|
||||
describe("useRecordingElapsedSeconds", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(BASE_MS);
|
||||
useRecordingStore.setState({
|
||||
recordingStartedAtMs: BASE_MS,
|
||||
manualCapturePaused: false,
|
||||
finishRequested: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("counts up while recording", () => {
|
||||
const { result } = renderHook(() => useRecordingElapsedSeconds());
|
||||
expect(result.current).toBe(0);
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(Math.floor(result.current)).toBe(3);
|
||||
});
|
||||
|
||||
it("freezes while paused and resumes without jumping the clock", () => {
|
||||
const { result } = renderHook(() => useRecordingElapsedSeconds());
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(Math.floor(result.current)).toBe(3);
|
||||
|
||||
act(() => {
|
||||
useRecordingStore.setState({ manualCapturePaused: true });
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
// Frozen: the 5s spent paused must not count.
|
||||
expect(Math.floor(result.current)).toBe(3);
|
||||
|
||||
act(() => {
|
||||
useRecordingStore.setState({ manualCapturePaused: false });
|
||||
});
|
||||
// Resume must not jump forward by the paused span.
|
||||
expect(Math.floor(result.current)).toBe(3);
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(Math.floor(result.current)).toBe(5);
|
||||
});
|
||||
});
|
||||
55
skyvern-frontend/src/hooks/useRecordingElapsedSeconds.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
|
||||
/**
|
||||
* Elapsed recording time in seconds, excluding any time spent manually paused.
|
||||
* The displayed clock freezes while `manualCapturePaused` is set and resumes
|
||||
* from where it left off — paused spans are folded into an accumulator rather
|
||||
* than counted, so resume never jumps the clock forward.
|
||||
*/
|
||||
export function useRecordingElapsedSeconds(): number {
|
||||
const startedAtMs = useRecordingStore((state) => state.recordingStartedAtMs);
|
||||
const paused = useRecordingStore((state) => state.manualCapturePaused);
|
||||
const finishRequested = useRecordingStore((state) => state.finishRequested);
|
||||
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const pausedAccumMsRef = useRef(0);
|
||||
const pauseStartedAtMsRef = useRef<number | null>(null);
|
||||
|
||||
// A new recording (new start timestamp) resets the paused accumulator.
|
||||
useEffect(() => {
|
||||
pausedAccumMsRef.current = 0;
|
||||
pauseStartedAtMsRef.current = null;
|
||||
}, [startedAtMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (finishRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
// Freeze: mark when the pause began and stop ticking.
|
||||
if (pauseStartedAtMsRef.current === null) {
|
||||
pauseStartedAtMsRef.current = Date.now();
|
||||
setNowMs(Date.now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resuming: fold the just-ended pause into the accumulator so it is not
|
||||
// counted, then keep ticking.
|
||||
if (pauseStartedAtMsRef.current !== null) {
|
||||
pausedAccumMsRef.current += Date.now() - pauseStartedAtMsRef.current;
|
||||
pauseStartedAtMsRef.current = null;
|
||||
}
|
||||
setNowMs(Date.now());
|
||||
const interval = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, finishRequested]);
|
||||
|
||||
if (!startedAtMs) {
|
||||
return 0;
|
||||
}
|
||||
return (nowMs - startedAtMs - pausedAccumMsRef.current) / 1000;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { useFeatureFlagEnabled } from "posthog-js/react";
|
||||
|
||||
// Read client-side so the flag reflects per-user opt-in (a PostHog person
|
||||
// property) rather than the per-org server-evaluated path.
|
||||
export function useWorkflowRunViewingV2(): boolean {
|
||||
return useFeatureFlagEnabled("workflow_run_viewing_v2") ?? false;
|
||||
}
|
||||
|
|
@ -54,11 +54,6 @@
|
|||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* studio editor brand accent (#6d6cf6) — opt-in via `studio-accent` utilities, scoped to the workflow studio */
|
||||
--studio-accent: 244.8 84.4% 60%;
|
||||
--studio-accent-foreground: 0 0% 100%;
|
||||
--studio-accent-2: 244.5 100% 71.4%;
|
||||
|
||||
--slate-elevation-1: 0 0% 98%;
|
||||
--slate-elevation-2: 0 0% 96%;
|
||||
--slate-elevation-3: 0 0% 93%;
|
||||
|
|
|
|||
4
skyvern-frontend/src/novnc.d.ts
vendored
|
|
@ -29,6 +29,10 @@ declare module "@novnc/novnc/lib/rfb.js" {
|
|||
_display: RfbDisplay;
|
||||
resizeSession: boolean;
|
||||
scaleViewport: boolean;
|
||||
// 0–9; JPEG quality for Tight-encoded regions (default 6).
|
||||
qualityLevel: number;
|
||||
// 0–9; zlib compression for stream data (default 2).
|
||||
compressionLevel: number;
|
||||
constructor(target: HTMLElement, url: string, options?: RFBOptions);
|
||||
|
||||
addEventListener(event: string, listener: (e: RfbEvent) => void): void;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
EditRoute,
|
||||
StudioRoute,
|
||||
} from "@/routes/workflows/StudioRouteGates";
|
||||
import { LegacyWorkflowsRedirect } from "@/routes/workflows/LegacyWorkflowsRedirect";
|
||||
import { BrowserSession } from "@/routes/browserSessions/BrowserSession";
|
||||
import { BrowserSessions } from "@/routes/browserSessions/BrowserSessions";
|
||||
import { PageLayout } from "./components/PageLayout";
|
||||
|
|
@ -216,7 +217,11 @@ const router = createBrowserRouter([
|
|||
],
|
||||
},
|
||||
{
|
||||
path: "workflows",
|
||||
path: "workflows/*",
|
||||
element: <LegacyWorkflowsRedirect />,
|
||||
},
|
||||
{
|
||||
path: "agents",
|
||||
element: <WorkflowsPageLayout />,
|
||||
children: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { Pencil1Icon } from "@radix-ui/react-icons";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { BrowserProfileApiResponse } from "@/api/types";
|
||||
import { CopyButton } from "@/components/CopyButton";
|
||||
import { SelectionCheckboxCell } from "@/components/SelectionCheckbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
|
|
@ -67,7 +69,37 @@ function BrowserProfileItem({
|
|||
/>
|
||||
)}
|
||||
<TableCell className="truncate">
|
||||
<span title={profile.name}>{profile.name}</span>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate" title={profile.name}>
|
||||
{profile.name}
|
||||
</span>
|
||||
{profile.is_managed ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
title="Auto-saved from a Save & Reuse Session workflow. Recreated automatically on the next run."
|
||||
>
|
||||
Auto-managed
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground"
|
||||
onClick={stopRowClick}
|
||||
>
|
||||
<span
|
||||
className="truncate font-mono"
|
||||
title={profile.browser_profile_id}
|
||||
>
|
||||
{profile.browser_profile_id}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={profile.browser_profile_id}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="truncate text-muted-foreground">
|
||||
{profile.description ? (
|
||||
|
|
|
|||
|
|
@ -49,9 +49,10 @@ import { BrowserProfileItem } from "./BrowserProfileItem";
|
|||
|
||||
type Props = {
|
||||
searchKey?: string;
|
||||
managed?: boolean;
|
||||
};
|
||||
|
||||
function BrowserProfilesList({ searchKey }: Props = {}) {
|
||||
function BrowserProfilesList({ searchKey, managed }: Props = {}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
|
||||
const itemsPerPage = searchParams.get("page_size")
|
||||
|
|
@ -84,12 +85,14 @@ function BrowserProfilesList({ searchKey }: Props = {}) {
|
|||
page,
|
||||
page_size: itemsPerPage,
|
||||
searchKey,
|
||||
managed,
|
||||
});
|
||||
|
||||
const { data: nextPageProfiles } = useBrowserProfilesQuery({
|
||||
page: page + 1,
|
||||
page_size: itemsPerPage,
|
||||
searchKey,
|
||||
managed,
|
||||
enabled: (profiles?.length ?? 0) === itemsPerPage,
|
||||
});
|
||||
|
||||
|
|
@ -124,7 +127,7 @@ function BrowserProfilesList({ searchKey }: Props = {}) {
|
|||
} = useRowSelection({
|
||||
items: pageItems,
|
||||
getId: (profile) => profile.browser_profile_id,
|
||||
resetKey: JSON.stringify([page, itemsPerPage, searchKey ?? ""]),
|
||||
resetKey: JSON.stringify([page, itemsPerPage, searchKey ?? "", managed]),
|
||||
});
|
||||
|
||||
async function handleBulkDeleteConfirm() {
|
||||
|
|
@ -211,6 +214,19 @@ function BrowserProfilesList({ searchKey }: Props = {}) {
|
|||
<div className="rounded-md border border-slate-700 bg-slate-elevation1 p-10 text-sm text-neutral-600 dark:text-slate-300">
|
||||
{hasSearch ? (
|
||||
<>No browser profiles match “{searchKey}”.</>
|
||||
) : managed === true ? (
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<BrowserIcon className="size-10 text-neutral-600 dark:text-slate-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-medium text-slate-100">
|
||||
No auto-managed profiles yet
|
||||
</p>
|
||||
<p className="mx-auto max-w-md text-sm text-neutral-600 dark:text-slate-400">
|
||||
Run a workflow with Save & Reuse Session enabled and Skyvern
|
||||
creates one automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<BrowserIcon className="size-10 text-neutral-600 dark:text-slate-400" />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ function BrowserProfilesPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search, 250);
|
||||
const [profileType, setProfileType] = useState<"all" | "user" | "managed">(
|
||||
"all",
|
||||
);
|
||||
const managed = profileType === "all" ? undefined : profileType === "managed";
|
||||
|
||||
function resetToFirstPage() {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
setSearchParams(params, { replace: true });
|
||||
}
|
||||
|
||||
// Mounted for its side effects: rehydrates an in-progress create from
|
||||
// sessionStorage so the toast still fires if the user navigates here from
|
||||
|
|
@ -34,20 +44,33 @@ function BrowserProfilesPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
setSearch(value);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
placeholder="Search browser profiles..."
|
||||
className="w-48 lg:w-72"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<TableSearchInput
|
||||
value={search}
|
||||
onChange={(value) => {
|
||||
setSearch(value);
|
||||
resetToFirstPage();
|
||||
}}
|
||||
placeholder="Search browser profiles..."
|
||||
className="w-48 lg:w-72"
|
||||
/>
|
||||
<select
|
||||
aria-label="Filter browser profiles by type"
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={profileType}
|
||||
onChange={(e) => {
|
||||
setProfileType(e.target.value as "all" | "user" | "managed");
|
||||
resetToFirstPage();
|
||||
}}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="user">User profiles</option>
|
||||
<option value="managed">Auto-managed</option>
|
||||
</select>
|
||||
</div>
|
||||
<CreateBrowserProfileButton />
|
||||
</div>
|
||||
<BrowserProfilesList searchKey={debouncedSearch} />
|
||||
<BrowserProfilesList searchKey={debouncedSearch} managed={managed} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ interface Props {
|
|||
centered?: boolean;
|
||||
onReadyChange?: (isReady: boolean, browserSessionId: string | null) => void;
|
||||
onUrlChange?: (url: string) => void;
|
||||
onActivity?: () => void;
|
||||
}
|
||||
|
||||
function BrowserSessionStream({
|
||||
|
|
@ -107,6 +108,7 @@ function BrowserSessionStream({
|
|||
centered = false,
|
||||
onReadyChange,
|
||||
onUrlChange,
|
||||
onActivity,
|
||||
}: Props) {
|
||||
const [streamImgSrc, setStreamImgSrc] = useState<string>("");
|
||||
const [streamFormat, setStreamFormat] = useState<string>("png");
|
||||
|
|
@ -119,6 +121,7 @@ function BrowserSessionStream({
|
|||
const settingsStore = useSettingsStore();
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const onActivityRef = useRef(onActivity);
|
||||
const hasFrameRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const terminalStatusSeenRef = useRef(false);
|
||||
|
|
@ -145,6 +148,18 @@ function BrowserSessionStream({
|
|||
viewportHeight,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onActivityRef.current = onActivity;
|
||||
}, [onActivity]);
|
||||
|
||||
// Once control can't be offered (input socket torn down), forget any prior
|
||||
// grab so re-enabling doesn't silently restore control without a new click.
|
||||
useEffect(() => {
|
||||
if (!controllable) {
|
||||
setUserIsControlling(false);
|
||||
}
|
||||
}, [controllable, setUserIsControlling]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setStreamImgSrc("");
|
||||
|
|
@ -188,6 +203,8 @@ function BrowserSessionStream({
|
|||
socketRef.current.addEventListener("message", (event) => {
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(event.data);
|
||||
const hasActivity =
|
||||
Boolean(message.screenshot) || message.url !== undefined;
|
||||
if (message.screenshot) {
|
||||
hasFrameRef.current = true;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
|
@ -205,6 +222,9 @@ function BrowserSessionStream({
|
|||
if (message.url !== undefined) {
|
||||
setCurrentUrl(message.url);
|
||||
}
|
||||
if (hasActivity) {
|
||||
onActivityRef.current?.();
|
||||
}
|
||||
if (!message.screenshot && message.status) {
|
||||
setDiagnostic(diagnosticForStatus(message.status));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ function BrowserSessionWorkflowRuns() {
|
|||
{runs.map((workflowRun) => {
|
||||
const url = env.useNewRunsUrl
|
||||
? `/runs/${workflowRun.workflow_run_id}`
|
||||
: `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/overview`;
|
||||
: `/agents/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/overview`;
|
||||
return (
|
||||
<TableRow
|
||||
key={workflowRun.workflow_run_id}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { useRef } from "react";
|
|||
import { getClient } from "@/api/AxiosClient";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
import {
|
||||
useRecordingStore,
|
||||
type RecordingDraftStep,
|
||||
} from "@/store/useRecordingStore";
|
||||
import {
|
||||
type WorkflowBlock,
|
||||
type WorkflowParameter,
|
||||
|
|
@ -33,7 +36,18 @@ const useProcessRecordingMutation = ({
|
|||
const mutationStartedAtRef = useRef<number | null>(null);
|
||||
|
||||
const processRecordingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (
|
||||
variables: {
|
||||
/**
|
||||
* Live-interpreted draft steps (with user edits/deletes applied).
|
||||
* When provided the backend converts them deterministically instead
|
||||
* of re-processing the raw event stream.
|
||||
*/
|
||||
draftSteps?: Array<RecordingDraftStep> | null;
|
||||
} | void,
|
||||
) => {
|
||||
const draftSteps = variables?.draftSteps ?? null;
|
||||
|
||||
if (!browserSessionId) {
|
||||
throw new Error(
|
||||
"Cannot process recording without a valid browser session ID.",
|
||||
|
|
@ -49,8 +63,9 @@ const useProcessRecordingMutation = ({
|
|||
mutationStartedAtRef.current = Date.now();
|
||||
|
||||
const eventCount = recordingStore.getEventCount();
|
||||
const hasDraftSteps = (draftSteps?.length ?? 0) > 0;
|
||||
|
||||
if (eventCount === 0) {
|
||||
if (eventCount === 0 && !hasDraftSteps) {
|
||||
captureRecordBrowser("record_browser.empty_blocked", {
|
||||
seconds_recording: recordingStore.getSecondsRecording(),
|
||||
});
|
||||
|
|
@ -62,12 +77,16 @@ const useProcessRecordingMutation = ({
|
|||
captureRecordBrowser("record_browser.process_attempted", {
|
||||
event_count: recordingStore.getEventCount(),
|
||||
compressed_chunk_count: compressedChunks.length,
|
||||
draft_step_count: draftSteps?.length,
|
||||
});
|
||||
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client
|
||||
.post<
|
||||
{ compressed_chunks: string[] },
|
||||
{
|
||||
compressed_chunks: string[];
|
||||
draft_steps?: Array<RecordingDraftStep>;
|
||||
},
|
||||
{
|
||||
data: {
|
||||
blocks: Array<WorkflowBlock>;
|
||||
|
|
@ -77,6 +96,7 @@ const useProcessRecordingMutation = ({
|
|||
>(`/browser_sessions/${browserSessionId}/process_recording`, {
|
||||
compressed_chunks: compressedChunks,
|
||||
workflow_permanent_id: workflowPermanentId,
|
||||
...(draftSteps !== null ? { draft_steps: draftSteps } : {}),
|
||||
})
|
||||
.then((response) => ({
|
||||
blocks: response.data.blocks,
|
||||
|
|
@ -112,6 +132,11 @@ const useProcessRecordingMutation = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// A zero-block commit still ends the session: the caller's onSuccess (which
|
||||
// normally exits recording after landing blocks) is skipped, and without
|
||||
// this the user is stranded in the recording panel with a dead Done button.
|
||||
recordingStore.setIsRecording(false);
|
||||
|
||||
toast({
|
||||
variant: "warning",
|
||||
title: "Recording Processed (No Blocks)",
|
||||
|
|
@ -141,6 +166,8 @@ const useProcessRecordingMutation = ({
|
|||
latency_ms: latencyMs,
|
||||
});
|
||||
|
||||
useRecordingStore.setState({ finishRequested: false });
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error Processing Recording",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
function AuthenticatorAppLogo({ src }: { src: string }) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="size-6 rounded-[6px] object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { AuthenticatorAppLogo };
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type CredentialEnterpriseAuthenticatorSupport = {
|
||||
label: string;
|
||||
apps: string[];
|
||||
description: ReactNode;
|
||||
contactUrl?: string;
|
||||
vendorLabels?: Record<string, string>;
|
||||
qrCodeTypes?: CredentialAuthenticatorQrCodeType[];
|
||||
inferQrCodeType?: (value: string) => string | null;
|
||||
};
|
||||
|
||||
type CredentialAuthenticatorQrCodeType = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
logo?: ReactNode;
|
||||
};
|
||||
|
||||
type CredentialAuthenticatorSupportCopy = {
|
||||
enterpriseApps?: CredentialEnterpriseAuthenticatorSupport;
|
||||
};
|
||||
|
||||
const CredentialAuthenticatorSupportContext =
|
||||
createContext<CredentialAuthenticatorSupportCopy>({});
|
||||
|
||||
function CredentialAuthenticatorSupportProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: CredentialAuthenticatorSupportCopy;
|
||||
}) {
|
||||
return createElement(
|
||||
CredentialAuthenticatorSupportContext.Provider,
|
||||
{ value },
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
function useCredentialAuthenticatorSupport() {
|
||||
return useContext(CredentialAuthenticatorSupportContext);
|
||||
}
|
||||
|
||||
export {
|
||||
CredentialAuthenticatorSupportProvider,
|
||||
useCredentialAuthenticatorSupport,
|
||||
};
|
||||
export type {
|
||||
CredentialAuthenticatorSupportCopy,
|
||||
CredentialAuthenticatorQrCodeType,
|
||||
CredentialEnterpriseAuthenticatorSupport,
|
||||
};
|
||||
|
|
@ -93,7 +93,14 @@ describe("CredentialItem TOTP code preview", () => {
|
|||
mockedGetClient.mockResolvedValue({
|
||||
get: vi.fn().mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { data: { detail: "Saved authenticator key is invalid." } },
|
||||
response: {
|
||||
data: {
|
||||
detail: {
|
||||
error_code: "invalid_authenticator_key",
|
||||
message: "Saved authenticator key is invalid.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as never);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ import { useCredentialTestStore } from "@/store/useCredentialTestStore";
|
|||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { copyText } from "@/util/copyText";
|
||||
import { isAxiosError } from "axios";
|
||||
import { cn } from "@/util/utils";
|
||||
import { getCredentialErrorMessage } from "./authenticatorSaveError";
|
||||
|
||||
type Props = {
|
||||
credential: CredentialApiResponse;
|
||||
|
|
@ -120,14 +120,11 @@ function CredentialTotpCodePreview({ credentialId }: { credentialId: string }) {
|
|||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
const detail = isAxiosError(caught)
|
||||
? (caught.response?.data as { detail?: string } | undefined)?.detail
|
||||
: undefined;
|
||||
setTotpCode(null);
|
||||
setSecondsRemaining(null);
|
||||
setExpiresAt(null);
|
||||
setIsCodeVisible(false);
|
||||
setError(detail ?? "Unable to load code");
|
||||
setError(getCredentialErrorMessage(caught) ?? "Unable to load code");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,130 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CredentialApiResponse } from "@/api/types";
|
||||
|
||||
import { getAuthenticatorKeyError } from "./credentialTotpValidation";
|
||||
import { CredentialsModal } from "./CredentialsModal";
|
||||
import { CredentialModalTypes } from "./useCredentialModalState";
|
||||
|
||||
const postMock = vi.hoisted(() => vi.fn());
|
||||
const patchMock = vi.hoisted(() => vi.fn());
|
||||
const deleteMock = vi.hoisted(() => vi.fn());
|
||||
const getMock = vi.hoisted(() => vi.fn());
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/AxiosClient", () => ({
|
||||
getClient: vi.fn(async () => ({
|
||||
post: postMock,
|
||||
patch: patchMock,
|
||||
delete: deleteMock,
|
||||
get: getMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/use-toast", () => ({
|
||||
toast: toastMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCredentialGetter", () => ({
|
||||
useCredentialGetter: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCustomCredentialServiceConfig", () => ({
|
||||
useCustomCredentialServiceConfig: () => ({ parsedConfig: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/routes/workflows/hooks/useCredentialsQuery", () => ({
|
||||
useCredentialsQuery: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
function axiosErrorWithDetail(detail: unknown): AxiosError {
|
||||
const error = new AxiosError("Request failed");
|
||||
error.response = {
|
||||
data: { detail },
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
};
|
||||
return error;
|
||||
}
|
||||
|
||||
function renderPasswordCredentialsModal() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CredentialsModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
overrideType={CredentialModalTypes.PASSWORD}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const editingPasswordCredential: CredentialApiResponse = {
|
||||
credential_id: "real-cred-id",
|
||||
credential_type: "password",
|
||||
name: "Acme Login",
|
||||
credential: {
|
||||
username: "user@example.com",
|
||||
totp_type: "none",
|
||||
totp_identifier: null,
|
||||
},
|
||||
browser_profile_id: "existing-profile-id",
|
||||
tested_url: "https://example.com/login",
|
||||
user_context: null,
|
||||
save_browser_session_intent: true,
|
||||
folder_id: null,
|
||||
proxy_location: null,
|
||||
proxy_session_id: null,
|
||||
};
|
||||
|
||||
function renderEditPasswordCredentialsModal() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CredentialsModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
overrideType={CredentialModalTypes.PASSWORD}
|
||||
editingCredential={editingPasswordCredential}
|
||||
onStartBackgroundTest={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getAuthenticatorKeyError", () => {
|
||||
it("requires an authenticator key when authenticator 2FA is selected", () => {
|
||||
|
|
@ -47,3 +169,164 @@ describe("getAuthenticatorKeyError", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CredentialsModal authenticator save errors", () => {
|
||||
async function fillAndSubmitAuthenticatorCredential(totp: string) {
|
||||
renderPasswordCredentialsModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("credentials")).toBeTruthy();
|
||||
});
|
||||
const usernameInput = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>("input"),
|
||||
).find(
|
||||
(input) =>
|
||||
input.type === "text" && input.value === "" && input.placeholder === "",
|
||||
);
|
||||
expect(usernameInput).toBeTruthy();
|
||||
fireEvent.change(usernameInput as HTMLInputElement, {
|
||||
target: { value: "user@example.com" },
|
||||
});
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
expect(passwordInput).toBeTruthy();
|
||||
fireEvent.change(passwordInput as HTMLInputElement, {
|
||||
target: { value: "password" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Two-Factor Authentication"));
|
||||
const authenticatorInput = screen.getByPlaceholderText(
|
||||
"e.g. JBSWY3DPEHPK3PXP",
|
||||
);
|
||||
fireEvent.change(authenticatorInput, {
|
||||
target: { value: totp },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
return authenticatorInput as HTMLInputElement;
|
||||
}
|
||||
|
||||
it("shows a rejected authenticator QR inline and keeps the submitted value in the field", async () => {
|
||||
postMock.mockRejectedValueOnce(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "authenticator_no_code_secret",
|
||||
message: "This QR code enrolls push approval.",
|
||||
vendor: "microsoft",
|
||||
}),
|
||||
);
|
||||
|
||||
const decodedQrPayload = "phonefactor://activate_account?code=123456";
|
||||
const authenticatorInput =
|
||||
await fillAndSubmitAuthenticatorCredential(decodedQrPayload);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
"/credentials",
|
||||
expect.objectContaining({
|
||||
credential: expect.objectContaining({
|
||||
totp: decodedQrPayload,
|
||||
totp_type: "authenticator",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/push-approval app/)).toBeTruthy();
|
||||
});
|
||||
expect((authenticatorInput as HTMLInputElement).value).toBe(
|
||||
decodedQrPayload,
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
it("shows enterprise-required feedback inline without a destructive toast", async () => {
|
||||
postMock.mockRejectedValueOnce(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "authenticator_feature_restricted",
|
||||
message: "Enterprise plan required.",
|
||||
vendor: "okta",
|
||||
}),
|
||||
);
|
||||
|
||||
await fillAndSubmitAuthenticatorCredential(
|
||||
'{"methods":[{"type":"totp","sharedSecret":"JBSWY3DPEHPK3PXP"}]}',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Skyvern enterprise plan/)).toBeTruthy();
|
||||
});
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("CredentialsModal edit-mode inline test", () => {
|
||||
it("updates the real credential and deletes the temp one instead of renaming the temp credential in place, even if 'save browser session' gets unchecked before saving", async () => {
|
||||
// 1st POST: startTest's /credentials/test-login. 2nd POST: the real
|
||||
// credential's /credentials/{id}/update once Save is clicked.
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
data: { credential_id: "temp-cred-id", workflow_run_id: "wr-1" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { credential_id: "real-cred-id", name: "Acme Login" },
|
||||
});
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
status: "completed",
|
||||
browser_profile_id: "new-profile-id",
|
||||
tested_url: "https://example.com/login",
|
||||
},
|
||||
});
|
||||
patchMock.mockResolvedValue({ data: {} });
|
||||
deleteMock.mockResolvedValue({ data: {} });
|
||||
|
||||
renderEditPasswordCredentialsModal();
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText("Edit credential values")[0]!);
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
expect(passwordInput).toBeTruthy();
|
||||
fireEvent.change(passwordInput as HTMLInputElement, {
|
||||
target: { value: "rotated-password" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test" }));
|
||||
|
||||
// Real 3s poll delay inside the component — wait for the button label
|
||||
// to flip once testStatus reaches "completed".
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole("button", { name: "Retest" })).toBeTruthy();
|
||||
},
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
|
||||
// Uncheck "Save browser session" after the test completed — the checkbox
|
||||
// has no side effect on testStatus/testCredentialId, so this must not be
|
||||
// able to skip cleanup of the now-orphaned temp credential.
|
||||
fireEvent.click(
|
||||
screen.getByLabelText("Save browser session for future logins"),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith("/credentials/temp-cred-id");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
"/credentials/real-cred-id/update",
|
||||
expect.objectContaining({
|
||||
credential: expect.objectContaining({
|
||||
password: "rotated-password",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The bug this guards against: the old code renamed the throwaway temp
|
||||
// credential in place of updating the real one. renameCredentialMutation
|
||||
// is the only path that PATCHes a credential by the *temp* id — assert
|
||||
// it never fired.
|
||||
for (const call of patchMock.mock.calls) {
|
||||
expect(call[0]).not.toBe("/credentials/temp-cred-id");
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { getHostname } from "@/util/getHostname";
|
||||
import { useCustomCredentialServiceConfig } from "@/hooks/useCustomCredentialServiceConfig";
|
||||
import { getAuthenticatorKeyError } from "./credentialTotpValidation";
|
||||
import {
|
||||
getAuthenticatorSaveError,
|
||||
getCredentialErrorMessage,
|
||||
type AuthenticatorSaveError,
|
||||
} from "./authenticatorSaveError";
|
||||
|
||||
const PASSWORD_CREDENTIAL_INITIAL_VALUES = {
|
||||
name: "",
|
||||
|
|
@ -248,6 +253,45 @@ function CredentialsModal({
|
|||
name: false,
|
||||
values: false,
|
||||
});
|
||||
// Inline authenticator setup error returned by the backend when it rejects a
|
||||
// decoded QR value or pasted key. Cleared whenever the user edits the key.
|
||||
const [authenticatorSaveError, setAuthenticatorSaveError] =
|
||||
useState<AuthenticatorSaveError | null>(null);
|
||||
|
||||
const handlePasswordCredentialChange = useCallback(
|
||||
(next: typeof PASSWORD_CREDENTIAL_INITIAL_VALUES) => {
|
||||
setPasswordCredentialValues((prev) => {
|
||||
if (next.totp !== prev.totp || next.totp_type !== prev.totp_type) {
|
||||
setAuthenticatorSaveError(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reportCredentialSaveError = useCallback(
|
||||
(error: unknown, title = "Error"): string => {
|
||||
const authError = getAuthenticatorSaveError(error);
|
||||
if (authError && passwordCredentialValues.totp_type === "authenticator") {
|
||||
setAuthenticatorSaveError(authError);
|
||||
}
|
||||
const description =
|
||||
authError?.message ??
|
||||
getCredentialErrorMessage(error) ??
|
||||
(error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred");
|
||||
const isEnterpriseUpgrade =
|
||||
authError?.code === "enterprise_required" &&
|
||||
passwordCredentialValues.totp_type === "authenticator";
|
||||
if (!isEnterpriseUpgrade) {
|
||||
toast({ title, description, variant: "destructive" });
|
||||
}
|
||||
return description;
|
||||
},
|
||||
[passwordCredentialValues.totp_type],
|
||||
);
|
||||
|
||||
const handleEnableEditName = useCallback(() => {
|
||||
setEditingGroups((prev) => ({ ...prev, name: true }));
|
||||
|
|
@ -461,6 +505,7 @@ function CredentialsModal({
|
|||
setCreditCardCredentialValues(createCreditCardCredentialInitialValues());
|
||||
setSecretCredentialValues(SECRET_CREDENTIAL_INITIAL_VALUES);
|
||||
setEditingGroups({ name: false, values: false });
|
||||
setAuthenticatorSaveError(null);
|
||||
setTestAndSave(false);
|
||||
setTestUrl("");
|
||||
setTestStatus("idle");
|
||||
|
|
@ -618,6 +663,7 @@ function CredentialsModal({
|
|||
);
|
||||
|
||||
const startTest = useCallback(async () => {
|
||||
setAuthenticatorSaveError(null);
|
||||
try {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
const proxyPinPayload = getProxyPinPayload();
|
||||
|
|
@ -664,15 +710,11 @@ function CredentialsModal({
|
|||
}, 3000);
|
||||
} catch (error) {
|
||||
setTestStatus("failed");
|
||||
const detail = (
|
||||
(error as AxiosError)?.response?.data as { detail?: string }
|
||||
)?.detail;
|
||||
setTestFailureReason(detail ?? "Failed to start credential test");
|
||||
toast({
|
||||
title: "Failed to start credential test",
|
||||
description: detail ?? "An unexpected error occurred",
|
||||
variant: "destructive",
|
||||
});
|
||||
const description = reportCredentialSaveError(
|
||||
error,
|
||||
"Failed to start credential test",
|
||||
);
|
||||
setTestFailureReason(description);
|
||||
}
|
||||
}, [
|
||||
credentialGetter,
|
||||
|
|
@ -681,6 +723,7 @@ function CredentialsModal({
|
|||
userContext,
|
||||
pollTestStatus,
|
||||
getProxyPinPayload,
|
||||
reportCredentialSaveError,
|
||||
]);
|
||||
|
||||
const createCredentialMutation = useMutation({
|
||||
|
|
@ -747,12 +790,7 @@ function CredentialsModal({
|
|||
}
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Error",
|
||||
description: detail ? detail : error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
reportCredentialSaveError(error);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -829,12 +867,7 @@ function CredentialsModal({
|
|||
}
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Error",
|
||||
description: detail ? detail : error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
reportCredentialSaveError(error);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -900,12 +933,7 @@ function CredentialsModal({
|
|||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||
toast({
|
||||
title: "Error",
|
||||
description: detail ? detail : error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
reportCredentialSaveError(error);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -969,9 +997,15 @@ function CredentialsModal({
|
|||
if (authenticatorKeyError) {
|
||||
return;
|
||||
}
|
||||
setAuthenticatorSaveError(null);
|
||||
|
||||
// If test passed, rename the temp credential instead of creating a new one
|
||||
if (testAndSave && testStatus === "completed" && testCredentialId) {
|
||||
const hasCompletedInlineTest =
|
||||
testAndSave && testStatus === "completed" && !!testCredentialId;
|
||||
|
||||
// Create mode: the temp credential created by the inline test already holds
|
||||
// the entered secret and the saved browser profile, so rename it in place
|
||||
// instead of creating a duplicate.
|
||||
if (!isEditMode && hasCompletedInlineTest && testCredentialId) {
|
||||
const url = testUrl.trim();
|
||||
const ctx = userContext.trim();
|
||||
renameCredentialMutation.mutate({
|
||||
|
|
@ -985,6 +1019,22 @@ function CredentialsModal({
|
|||
return;
|
||||
}
|
||||
|
||||
// Any remaining temp credential is an orphan we're not reusing — either
|
||||
// we're in edit mode (the real credential gets updated below instead),
|
||||
// or "Save browser session" got unchecked after a completed test. Key
|
||||
// this on testCredentialId alone, not hasCompletedInlineTest: the
|
||||
// checkbox is togglable post-test with no side effect on testStatus, so
|
||||
// gating on testAndSave here silently leaked the temp credential (and
|
||||
// the secret it holds) whenever the box was unchecked before saving.
|
||||
if (testCredentialId) {
|
||||
const tempCredentialId = testCredentialId;
|
||||
getClient(credentialGetter)
|
||||
.then((client) => client.delete(`/credentials/${tempCredentialId}`))
|
||||
.catch(() => {
|
||||
// Best-effort cleanup
|
||||
});
|
||||
}
|
||||
|
||||
// Capture intent before mutation — state will be reset in onSuccess
|
||||
// In edit mode, only trigger a background test if the user actually changed
|
||||
// credentials, user_context, or URL — not just because the checkbox was pre-checked.
|
||||
|
|
@ -996,7 +1046,8 @@ function CredentialsModal({
|
|||
saveIntentRef.current = {
|
||||
shouldTestAfterSave:
|
||||
testAndSave &&
|
||||
testStatus !== "completed" &&
|
||||
(testStatus !== "completed" ||
|
||||
(isEditMode && hasCompletedInlineTest)) &&
|
||||
testUrl.trim() !== "" &&
|
||||
hasEditModeChanges,
|
||||
saveBrowserSessionIntent: testAndSave,
|
||||
|
|
@ -1263,7 +1314,7 @@ function CredentialsModal({
|
|||
return (
|
||||
<PasswordCredentialContent
|
||||
values={passwordCredentialValues}
|
||||
onChange={setPasswordCredentialValues}
|
||||
onChange={handlePasswordCredentialChange}
|
||||
url={testUrl}
|
||||
onUrlChange={setTestUrl}
|
||||
urlRequired={testAndSave}
|
||||
|
|
@ -1273,6 +1324,7 @@ function CredentialsModal({
|
|||
onEnableEditName={handleEnableEditName}
|
||||
onEnableEditValues={handleEnableEditValues}
|
||||
totpError={authenticatorKeyError}
|
||||
authenticatorSaveError={authenticatorSaveError}
|
||||
beforeCredentialFields={customVaultCheckbox}
|
||||
afterUrl={
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
|||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CredentialAuthenticatorSupportProvider } from "./CredentialAuthenticatorSupportContext";
|
||||
import { PasswordCredentialContent } from "./PasswordCredentialContent";
|
||||
|
||||
type Values = {
|
||||
|
|
@ -41,6 +42,36 @@ const SAVED_VALUES: Values = {
|
|||
totp_identifier: "saved-totp-id@example.com",
|
||||
};
|
||||
|
||||
const ENTERPRISE_APPS = {
|
||||
label: "Enterprise QR support",
|
||||
apps: ["Enterprise Authenticator A", "Enterprise Authenticator B"],
|
||||
contactUrl: "https://www.skyvern.com/contact",
|
||||
qrCodeTypes: [
|
||||
{
|
||||
id: "example",
|
||||
label: "Enterprise Authenticator A",
|
||||
},
|
||||
{
|
||||
id: "opaque",
|
||||
label: "Enterprise Authenticator B",
|
||||
},
|
||||
],
|
||||
inferQrCodeType: (value: string) => {
|
||||
if (value.startsWith("example-authenticator://")) {
|
||||
return "example";
|
||||
}
|
||||
if (value.startsWith("opaque-authenticator://")) {
|
||||
return "opaque";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
vendorLabels: {
|
||||
opaque: "Enterprise Authenticator B",
|
||||
},
|
||||
description:
|
||||
"Scan the QR as usual - Skyvern detects these setup codes automatically.",
|
||||
};
|
||||
|
||||
// Mirrors CredentialsModal: starts the form at INITIAL_VALUES, then after mount
|
||||
// applies the loaded credential via setPasswordCredentialValues — same flow
|
||||
// that produced SKY-9864.
|
||||
|
|
@ -267,6 +298,14 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression
|
|||
});
|
||||
expect(qrCall).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: /Google Authenticator/ })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
screen.getByTestId("authenticator-qr-type-detection").textContent,
|
||||
).toBe("Detected from QR");
|
||||
expect(imageBitmap.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -318,6 +357,12 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression
|
|||
});
|
||||
expect(qrCall).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: /Google Authenticator/ })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("false");
|
||||
expect(screen.queryByText("Detected from QR")).toBeNull();
|
||||
expect(imageBitmap.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -561,3 +606,321 @@ describe("PasswordCredentialContent — edit-mode hydration (SKY-9864 regression
|
|||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasswordCredentialContent — supported authenticator copy", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const NEW_VALUES: Values = {
|
||||
name: "New cred",
|
||||
username: "new-user@example.com",
|
||||
password: "password",
|
||||
totp: "",
|
||||
totp_type: "none",
|
||||
totp_identifier: "",
|
||||
};
|
||||
|
||||
function stubQrCodeScan(rawValue: string) {
|
||||
const imageBitmap = { close: vi.fn() } as unknown as ImageBitmap;
|
||||
class MockBarcodeDetector {
|
||||
detect = vi.fn().mockResolvedValue([{ rawValue }]);
|
||||
}
|
||||
vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(imageBitmap));
|
||||
vi.stubGlobal("BarcodeDetector", MockBarcodeDetector);
|
||||
return imageBitmap;
|
||||
}
|
||||
|
||||
async function uploadQrCodeImage() {
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText("Upload QR code image"), {
|
||||
target: {
|
||||
files: [new File(["qr"], "totp.png", { type: "image/png" })],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openAuthenticatorSection(
|
||||
enterpriseApps?: typeof ENTERPRISE_APPS,
|
||||
onChangeSpy = vi.fn(),
|
||||
) {
|
||||
const content = (
|
||||
<MemoryRouter>
|
||||
<PasswordCredentialContent values={NEW_VALUES} onChange={onChangeSpy} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(
|
||||
enterpriseApps ? (
|
||||
<CredentialAuthenticatorSupportProvider value={{ enterpriseApps }}>
|
||||
{content}
|
||||
</CredentialAuthenticatorSupportProvider>
|
||||
) : (
|
||||
content
|
||||
),
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Two-Factor Authentication"));
|
||||
});
|
||||
return onChangeSpy;
|
||||
}
|
||||
|
||||
it("always lists generic TOTP apps", async () => {
|
||||
await openAuthenticatorSection();
|
||||
expect(
|
||||
screen.getByText(/Google Authenticator, Authy, 1Password/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits enterprise QR type options in the default/OSS context", async () => {
|
||||
await openAuthenticatorSection();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Google Authenticator/ }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("Enterprise Authenticator A")).toBeNull();
|
||||
expect(screen.queryByText("Enterprise Authenticator B")).toBeNull();
|
||||
expect(
|
||||
screen.queryByText(/detects these setup codes automatically/),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Cloud provider QR type options when the context provides apps", async () => {
|
||||
await openAuthenticatorSection(ENTERPRISE_APPS);
|
||||
const selector = screen.getByTestId("authenticator-qr-type-selector");
|
||||
expect(selector).toBeTruthy();
|
||||
const keyInput = screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP");
|
||||
expect(
|
||||
selector.compareDocumentPosition(keyInput) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Google Authenticator/ }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Enterprise Authenticator A" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Enterprise Authenticator B" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/detects these setup codes automatically/),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
selector.querySelectorAll('[data-testid="authenticator-type-logo"]'),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("highlights the Cloud provider QR type inferred from an uploaded QR code", async () => {
|
||||
const imageBitmap = stubQrCodeScan(
|
||||
"example-authenticator://activate?payload=opaque",
|
||||
);
|
||||
const onChangeSpy = await openAuthenticatorSection(ENTERPRISE_APPS);
|
||||
await uploadQrCodeImage();
|
||||
|
||||
await waitFor(() => {
|
||||
const qrCall = onChangeSpy.mock.calls.find((call) => {
|
||||
const next = call[0] as Values;
|
||||
return (
|
||||
next.totp_type === "authenticator" &&
|
||||
next.totp === "example-authenticator://activate?payload=opaque"
|
||||
);
|
||||
});
|
||||
expect(qrCall).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: /Enterprise Authenticator A/ })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
screen.getByTestId("authenticator-qr-type-detection").textContent,
|
||||
).toBe("Detected from QR");
|
||||
expect(imageBitmap.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces the inferred type via a polite live region naming the detected app", async () => {
|
||||
stubQrCodeScan("example-authenticator://activate?payload=opaque");
|
||||
await openAuthenticatorSection(ENTERPRISE_APPS);
|
||||
await uploadQrCodeImage();
|
||||
|
||||
const liveRegion = await waitFor(() => {
|
||||
const region = screen
|
||||
.getByTestId("authenticator-qr-type-detection")
|
||||
.closest('[role="status"]');
|
||||
expect(region).toBeTruthy();
|
||||
return region as HTMLElement;
|
||||
});
|
||||
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
|
||||
expect(liveRegion.textContent).toContain("Detected from QR");
|
||||
expect(liveRegion.textContent).toContain("Enterprise Authenticator A");
|
||||
expect(
|
||||
screen.getByTestId("authenticator-qr-type-detection").textContent,
|
||||
).toBe("Detected from QR");
|
||||
});
|
||||
|
||||
it("visually marks only the inferred chip as detected, and clears that marker when the user picks another type", async () => {
|
||||
stubQrCodeScan("example-authenticator://activate?payload=opaque");
|
||||
await openAuthenticatorSection(ENTERPRISE_APPS);
|
||||
await uploadQrCodeImage();
|
||||
|
||||
const inferredButton = await waitFor(() => {
|
||||
const button = screen.getByRole("button", {
|
||||
name: /Enterprise Authenticator A/,
|
||||
});
|
||||
expect(button.getAttribute("data-inferred")).toBe("true");
|
||||
return button;
|
||||
});
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: /Google Authenticator/ })
|
||||
.getAttribute("data-inferred"),
|
||||
).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /Google Authenticator/ }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(inferredButton.getAttribute("data-inferred")).toBeNull();
|
||||
expect(screen.queryByText("Detected from QR")).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: /Google Authenticator/ })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("does not falsely highlight a Cloud enterprise type for an unknown uploaded QR code", async () => {
|
||||
const imageBitmap = stubQrCodeScan(
|
||||
"unknown-authenticator://activate?payload=opaque",
|
||||
);
|
||||
const onChangeSpy = await openAuthenticatorSection(ENTERPRISE_APPS);
|
||||
await uploadQrCodeImage();
|
||||
|
||||
await waitFor(() => {
|
||||
const qrCall = onChangeSpy.mock.calls.find((call) => {
|
||||
const next = call[0] as Values;
|
||||
return (
|
||||
next.totp_type === "authenticator" &&
|
||||
next.totp === "unknown-authenticator://activate?payload=opaque"
|
||||
);
|
||||
});
|
||||
expect(qrCall).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: /Google Authenticator/ })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("false");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Enterprise Authenticator A" })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("false");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Enterprise Authenticator B" })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("false");
|
||||
expect(screen.queryByText("Detected from QR")).toBeNull();
|
||||
expect(imageBitmap.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasswordCredentialContent — inline authenticator save error", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const AUTH_VALUES: Values = {
|
||||
name: "New cred",
|
||||
username: "new-user@example.com",
|
||||
password: "password",
|
||||
totp: "otpauth://totp/user@example.com?secret=BAD",
|
||||
totp_type: "authenticator",
|
||||
totp_identifier: "",
|
||||
};
|
||||
|
||||
it("shows enterprise copy with a contact link for an enterprise-required error", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CredentialAuthenticatorSupportProvider
|
||||
value={{ enterpriseApps: ENTERPRISE_APPS }}
|
||||
>
|
||||
<PasswordCredentialContent
|
||||
values={AUTH_VALUES}
|
||||
onChange={vi.fn()}
|
||||
authenticatorSaveError={{
|
||||
code: "enterprise_required",
|
||||
message: "This authenticator requires a Skyvern enterprise plan.",
|
||||
vendor: "opaque",
|
||||
}}
|
||||
/>
|
||||
</CredentialAuthenticatorSupportProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const enterpriseMessage = screen.getByText(
|
||||
"Enterprise Authenticator B requires a Skyvern enterprise plan.",
|
||||
);
|
||||
expect(enterpriseMessage.closest(".text-destructive")).toBeNull();
|
||||
expect(screen.getByTestId("enterprise-authenticator-upgrade")).toBeTruthy();
|
||||
const contactLink = screen.getByRole("link", { name: "Contact us" });
|
||||
expect(contactLink.getAttribute("href")).toBe(
|
||||
"https://www.skyvern.com/contact",
|
||||
);
|
||||
const input = screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP");
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(input.className).not.toContain("border-destructive");
|
||||
expect(input.getAttribute("aria-describedby")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows actionable no-code-secret copy without a contact link", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PasswordCredentialContent
|
||||
values={AUTH_VALUES}
|
||||
onChange={vi.fn()}
|
||||
authenticatorSaveError={{
|
||||
code: "no_code_secret",
|
||||
message:
|
||||
"This QR code doesn't contain a code-based setup key. It may enroll a push-approval app or device-bound authenticator. Set up an authenticator app or one-time code instead.",
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/push-approval app/)).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "Contact us" })).toBeNull();
|
||||
const input = screen.getByPlaceholderText("e.g. JBSWY3DPEHPK3PXP");
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(input.className).toContain("border-destructive");
|
||||
const describedBy = input.getAttribute("aria-describedby");
|
||||
expect(describedBy).toBeTruthy();
|
||||
expect(document.getElementById(describedBy!)?.textContent).toContain(
|
||||
"push-approval app",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the decoded QR value in the field after a save failure", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PasswordCredentialContent
|
||||
values={AUTH_VALUES}
|
||||
onChange={vi.fn()}
|
||||
authenticatorSaveError={{
|
||||
code: "invalid_authenticator_key",
|
||||
message: "Invalid authenticator key.",
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"e.g. JBSWY3DPEHPK3PXP",
|
||||
) as HTMLInputElement;
|
||||
expect(input.value).toBe("otpauth://totp/user@example.com?secret=BAD");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import googleAuthenticatorIcon from "@/assets/authenticators/google-authenticator.jpg";
|
||||
import { QRCodeIcon } from "@/components/icons/QRCodeIcon";
|
||||
import {
|
||||
Accordion,
|
||||
|
|
@ -15,14 +16,28 @@ import {
|
|||
EnvelopeClosedIcon,
|
||||
EyeNoneIcon,
|
||||
EyeOpenIcon,
|
||||
MagicWandIcon,
|
||||
MobileIcon,
|
||||
Pencil1Icon,
|
||||
ReloadIcon,
|
||||
UploadIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type CredentialAuthenticatorQrCodeType,
|
||||
useCredentialAuthenticatorSupport,
|
||||
} from "./CredentialAuthenticatorSupportContext";
|
||||
import { AuthenticatorAppLogo } from "./AuthenticatorAppLogo";
|
||||
import { decodeQrCodeImage } from "./decodeQrCodeImage";
|
||||
import { type AuthenticatorSaveError } from "./authenticatorSaveError";
|
||||
|
||||
type Props = {
|
||||
values: {
|
||||
|
|
@ -57,8 +72,39 @@ type Props = {
|
|||
onEnableEditName?: () => void;
|
||||
onEnableEditValues?: () => void;
|
||||
totpError?: string | null;
|
||||
/** Server-side authenticator setup error surfaced inline near the key field */
|
||||
authenticatorSaveError?: AuthenticatorSaveError | null;
|
||||
};
|
||||
|
||||
const STANDARD_AUTHENTICATOR_TYPE_ID = "standard";
|
||||
|
||||
const STANDARD_AUTHENTICATOR_TYPE: CredentialAuthenticatorQrCodeType = {
|
||||
id: STANDARD_AUTHENTICATOR_TYPE_ID,
|
||||
label: "Google Authenticator",
|
||||
description: "Standard TOTP setup code",
|
||||
logo: <AuthenticatorAppLogo src={googleAuthenticatorIcon} />,
|
||||
};
|
||||
|
||||
type AuthenticatorTypeDetectionSource = "qr" | "typed" | "saved" | "backend";
|
||||
|
||||
function compactAuthenticatorSecret(value: string): string {
|
||||
return value.replace(/[\s-]/g, "");
|
||||
}
|
||||
|
||||
function isStandardAuthenticatorValue(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^otpauth:\/\/totp\//i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const compact = compactAuthenticatorSecret(trimmed);
|
||||
return compact.length >= 16 && /^[A-Z2-7]+=*$/i.test(compact);
|
||||
}
|
||||
|
||||
function PasswordCredentialContent({
|
||||
values,
|
||||
onChange,
|
||||
|
|
@ -73,8 +119,29 @@ function PasswordCredentialContent({
|
|||
onEnableEditName,
|
||||
onEnableEditValues,
|
||||
totpError,
|
||||
authenticatorSaveError,
|
||||
}: Props) {
|
||||
const { enterpriseApps } = useCredentialAuthenticatorSupport();
|
||||
const { name, username, password, totp, totp_type, totp_identifier } = values;
|
||||
|
||||
const enterpriseUpgradeError =
|
||||
authenticatorSaveError?.code === "enterprise_required"
|
||||
? authenticatorSaveError
|
||||
: null;
|
||||
const enterpriseUpgradeVendorLabel = enterpriseUpgradeError?.vendor
|
||||
? enterpriseApps?.vendorLabels?.[
|
||||
enterpriseUpgradeError.vendor.toLowerCase()
|
||||
]
|
||||
: null;
|
||||
const enterpriseUpgradeMessage = enterpriseUpgradeVendorLabel
|
||||
? `${enterpriseUpgradeVendorLabel} requires a Skyvern enterprise plan.`
|
||||
: enterpriseUpgradeError?.message;
|
||||
const enterpriseContactUrl = enterpriseApps?.contactUrl;
|
||||
const destructiveSaveError =
|
||||
authenticatorSaveError &&
|
||||
authenticatorSaveError.code !== "enterprise_required"
|
||||
? authenticatorSaveError
|
||||
: null;
|
||||
const nameReadOnly = editMode && !editingGroups?.name;
|
||||
const valuesReadOnly = editMode && !editingGroups?.values;
|
||||
|
||||
|
|
@ -87,7 +154,101 @@ function PasswordCredentialContent({
|
|||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [qrCodeScanError, setQrCodeScanError] = useState<string | null>(null);
|
||||
const [isScanningQrCode, setIsScanningQrCode] = useState(false);
|
||||
const [selectedAuthenticatorTypeId, setSelectedAuthenticatorTypeId] =
|
||||
useState<string | null>(STANDARD_AUTHENTICATOR_TYPE_ID);
|
||||
const [inferredAuthenticatorTypeId, setInferredAuthenticatorTypeId] =
|
||||
useState<string | null>(null);
|
||||
const [
|
||||
authenticatorTypeDetectionSource,
|
||||
setAuthenticatorTypeDetectionSource,
|
||||
] = useState<AuthenticatorTypeDetectionSource | null>(null);
|
||||
const inferredAuthenticatorValueRef = useRef("");
|
||||
const qrCodeInputRef = useRef<HTMLInputElement>(null);
|
||||
const authenticatorKeyErrorId = useId();
|
||||
const enterpriseUpgradeErrorId = useId();
|
||||
|
||||
const authenticatorTypeOptions = useMemo(
|
||||
() => [STANDARD_AUTHENTICATOR_TYPE, ...(enterpriseApps?.qrCodeTypes ?? [])],
|
||||
[enterpriseApps?.qrCodeTypes],
|
||||
);
|
||||
const authenticatorTypeOptionIds = useMemo(
|
||||
() => new Set(authenticatorTypeOptions.map(({ id }) => id)),
|
||||
[authenticatorTypeOptions],
|
||||
);
|
||||
const detectionStatusLabel =
|
||||
authenticatorTypeDetectionSource === "qr"
|
||||
? "Detected from QR"
|
||||
: authenticatorTypeDetectionSource === "typed"
|
||||
? "Detected from value"
|
||||
: authenticatorTypeDetectionSource === "saved"
|
||||
? "Detected from saved key"
|
||||
: authenticatorTypeDetectionSource === "backend"
|
||||
? "Detected on save"
|
||||
: null;
|
||||
const inferredAuthenticatorTypeLabel = useMemo(
|
||||
() =>
|
||||
authenticatorTypeOptions.find(
|
||||
({ id }) => id === inferredAuthenticatorTypeId,
|
||||
)?.label ?? null,
|
||||
[authenticatorTypeOptions, inferredAuthenticatorTypeId],
|
||||
);
|
||||
const destructiveAuthenticatorMessages = (
|
||||
[totpError, qrCodeScanError, destructiveSaveError?.message] as const
|
||||
).filter((message): message is string => Boolean(message));
|
||||
const hasAuthenticatorKeyError = Boolean(
|
||||
destructiveAuthenticatorMessages.length,
|
||||
);
|
||||
const authenticatorKeyDescriptionIds = [
|
||||
hasAuthenticatorKeyError ? authenticatorKeyErrorId : null,
|
||||
enterpriseUpgradeError ? enterpriseUpgradeErrorId : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const inferAuthenticatorTypeId = useCallback(
|
||||
(value: string): string | null => {
|
||||
if (isStandardAuthenticatorValue(value)) {
|
||||
return STANDARD_AUTHENTICATOR_TYPE_ID;
|
||||
}
|
||||
|
||||
const inferredEnterpriseType = enterpriseApps?.inferQrCodeType?.(value);
|
||||
if (
|
||||
inferredEnterpriseType &&
|
||||
authenticatorTypeOptionIds.has(inferredEnterpriseType)
|
||||
) {
|
||||
return inferredEnterpriseType;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[authenticatorTypeOptionIds, enterpriseApps],
|
||||
);
|
||||
|
||||
const applyAuthenticatorTypeInference = useCallback(
|
||||
(
|
||||
value: string,
|
||||
source: AuthenticatorTypeDetectionSource,
|
||||
{
|
||||
clearUnknownSelection = false,
|
||||
}: { clearUnknownSelection?: boolean } = {},
|
||||
) => {
|
||||
const inferredTypeId = inferAuthenticatorTypeId(value);
|
||||
if (inferredTypeId) {
|
||||
inferredAuthenticatorValueRef.current = value;
|
||||
setSelectedAuthenticatorTypeId(inferredTypeId);
|
||||
setInferredAuthenticatorTypeId(inferredTypeId);
|
||||
setAuthenticatorTypeDetectionSource(source);
|
||||
return;
|
||||
}
|
||||
|
||||
setInferredAuthenticatorTypeId(null);
|
||||
setAuthenticatorTypeDetectionSource(null);
|
||||
if (clearUnknownSelection) {
|
||||
setSelectedAuthenticatorTypeId(null);
|
||||
}
|
||||
},
|
||||
[inferAuthenticatorTypeId],
|
||||
);
|
||||
|
||||
// Sync totpMethod and auto-expand accordion when totp_type prop changes
|
||||
// (e.g. edit data arriving after mount)
|
||||
|
|
@ -101,6 +262,53 @@ function PasswordCredentialContent({
|
|||
setTotpAccordionValue("two-factor-authentication");
|
||||
}
|
||||
}, [totp_type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedAuthenticatorTypeId &&
|
||||
!authenticatorTypeOptionIds.has(selectedAuthenticatorTypeId)
|
||||
) {
|
||||
setSelectedAuthenticatorTypeId(STANDARD_AUTHENTICATOR_TYPE_ID);
|
||||
}
|
||||
if (
|
||||
inferredAuthenticatorTypeId &&
|
||||
!authenticatorTypeOptionIds.has(inferredAuthenticatorTypeId)
|
||||
) {
|
||||
setInferredAuthenticatorTypeId(null);
|
||||
setAuthenticatorTypeDetectionSource(null);
|
||||
}
|
||||
}, [
|
||||
authenticatorTypeOptionIds,
|
||||
inferredAuthenticatorTypeId,
|
||||
selectedAuthenticatorTypeId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (totpMethod !== "authenticator" || !totp) {
|
||||
return;
|
||||
}
|
||||
if (totp === inferredAuthenticatorValueRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inferredTypeId = inferAuthenticatorTypeId(totp);
|
||||
if (inferredTypeId) {
|
||||
inferredAuthenticatorValueRef.current = totp;
|
||||
setSelectedAuthenticatorTypeId(inferredTypeId);
|
||||
setInferredAuthenticatorTypeId(inferredTypeId);
|
||||
setAuthenticatorTypeDetectionSource("saved");
|
||||
}
|
||||
}, [inferAuthenticatorTypeId, totp, totpMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
const vendor = enterpriseUpgradeError?.vendor?.toLowerCase();
|
||||
if (vendor && authenticatorTypeOptionIds.has(vendor)) {
|
||||
setSelectedAuthenticatorTypeId(vendor);
|
||||
setInferredAuthenticatorTypeId(vendor);
|
||||
setAuthenticatorTypeDetectionSource("backend");
|
||||
}
|
||||
}, [authenticatorTypeOptionIds, enterpriseUpgradeError?.vendor]);
|
||||
|
||||
const prevUsernameRef = useRef(username);
|
||||
const totpIdentifierLabel =
|
||||
totpMethod === "text"
|
||||
|
|
@ -196,6 +404,14 @@ function PasswordCredentialContent({
|
|||
onEnableEditValues?.();
|
||||
setQrCodeScanError(null);
|
||||
setTotpMethod("authenticator");
|
||||
if (!value) {
|
||||
inferredAuthenticatorValueRef.current = "";
|
||||
setSelectedAuthenticatorTypeId(STANDARD_AUTHENTICATOR_TYPE_ID);
|
||||
setInferredAuthenticatorTypeId(null);
|
||||
setAuthenticatorTypeDetectionSource(null);
|
||||
} else {
|
||||
applyAuthenticatorTypeInference(value, "typed");
|
||||
}
|
||||
updateValues({ totp: value, totp_type: "authenticator" });
|
||||
};
|
||||
|
||||
|
|
@ -214,6 +430,9 @@ function PasswordCredentialContent({
|
|||
try {
|
||||
const qrCodeValue = await decodeQrCodeImage(file);
|
||||
setTotpMethod("authenticator");
|
||||
applyAuthenticatorTypeInference(qrCodeValue, "qr", {
|
||||
clearUnknownSelection: true,
|
||||
});
|
||||
updateValues({ totp: qrCodeValue, totp_type: "authenticator" });
|
||||
} catch (caught) {
|
||||
setQrCodeScanError(
|
||||
|
|
@ -226,6 +445,94 @@ function PasswordCredentialContent({
|
|||
}
|
||||
};
|
||||
|
||||
const authenticatorTypeSelector = (
|
||||
<div className="space-y-2" data-testid="authenticator-qr-type-selector">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-xs font-medium text-slate-400">
|
||||
Authenticator app
|
||||
</Label>
|
||||
<div role="status" aria-live="polite" className="min-h-4">
|
||||
{detectionStatusLabel && inferredAuthenticatorTypeId && (
|
||||
<>
|
||||
<span
|
||||
data-testid="authenticator-qr-type-detection"
|
||||
className="text-xs text-blue-300"
|
||||
>
|
||||
{detectionStatusLabel}
|
||||
</span>
|
||||
{inferredAuthenticatorTypeLabel && (
|
||||
<span className="sr-only">
|
||||
{` — ${inferredAuthenticatorTypeLabel}`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Authenticator app type"
|
||||
className="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||
>
|
||||
{authenticatorTypeOptions.map((option) => {
|
||||
const selected = selectedAuthenticatorTypeId === option.id;
|
||||
const inferred = inferredAuthenticatorTypeId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
data-inferred={inferred ? "true" : undefined}
|
||||
className={cn(
|
||||
"inline-flex h-14 w-full min-w-0 items-center gap-2 rounded-md border px-3 text-left text-xs font-medium leading-tight transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
selected
|
||||
? "border-blue-400 bg-blue-500/10 text-foreground"
|
||||
: "border-slate-700 bg-slate-elevation1 text-slate-300 hover:border-slate-500 hover:bg-slate-elevation3 hover:text-slate-100",
|
||||
inferred &&
|
||||
"border-blue-300 bg-blue-500/15 ring-1 ring-blue-400/70",
|
||||
)}
|
||||
onClick={() => {
|
||||
onEnableEditValues?.();
|
||||
setSelectedAuthenticatorTypeId(option.id);
|
||||
setInferredAuthenticatorTypeId(null);
|
||||
setAuthenticatorTypeDetectionSource(null);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="authenticator-type-logo"
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md border",
|
||||
selected || inferred
|
||||
? "border-blue-400/50 bg-blue-500/10"
|
||||
: "border-slate-700 bg-background/40",
|
||||
)}
|
||||
>
|
||||
{option.logo ?? <QRCodeIcon className="size-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">{option.label}</span>
|
||||
<span className="ml-auto flex size-4 shrink-0 items-center justify-center">
|
||||
{inferred ? (
|
||||
<MagicWandIcon className="size-3 text-blue-300" />
|
||||
) : (
|
||||
selected && <CheckIcon className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
{inferred && detectionStatusLabel && (
|
||||
<span className="sr-only">{detectionStatusLabel}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{enterpriseApps?.description && (
|
||||
<p className="text-xs leading-5 text-slate-400">
|
||||
{enterpriseApps.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-12">
|
||||
|
|
@ -448,24 +755,28 @@ function PasswordCredentialContent({
|
|||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
<Link
|
||||
to="https://www.skyvern.com/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Contact us to set up two-factor authentication in
|
||||
workflows
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
{enterpriseContactUrl && (
|
||||
<>
|
||||
<Link
|
||||
to={enterpriseContactUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Contact us to set up two-factor authentication in
|
||||
workflows
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
to="https://www.skyvern.com/docs/running-tasks/advanced-features#time-based-one-time-password-totp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
see our documentation on how to set up two-factor
|
||||
authentication in workflows
|
||||
{enterpriseContactUrl ? "see" : "See"} our documentation
|
||||
on how to set up two-factor authentication in workflows
|
||||
</Link>{" "}
|
||||
to get started.
|
||||
</p>
|
||||
|
|
@ -473,6 +784,7 @@ function PasswordCredentialContent({
|
|||
)}
|
||||
{totpMethod === "authenticator" && (
|
||||
<div className="space-y-4">
|
||||
{authenticatorTypeSelector}
|
||||
<div className="flex items-center gap-12">
|
||||
<div className="w-40 shrink-0">
|
||||
<Label className="whitespace-nowrap">
|
||||
|
|
@ -504,9 +816,12 @@ function PasswordCredentialContent({
|
|||
handleAuthenticatorTotpChange(e.target.value)
|
||||
}
|
||||
placeholder="e.g. JBSWY3DPEHPK3PXP"
|
||||
aria-invalid={Boolean(totpError)}
|
||||
aria-invalid={hasAuthenticatorKeyError}
|
||||
aria-describedby={
|
||||
authenticatorKeyDescriptionIds || undefined
|
||||
}
|
||||
className={cn(
|
||||
totpError &&
|
||||
hasAuthenticatorKeyError &&
|
||||
"border-destructive bg-destructive/10 focus-visible:ring-destructive/30",
|
||||
)}
|
||||
/>
|
||||
|
|
@ -537,12 +852,49 @@ function PasswordCredentialContent({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(totpError || qrCodeScanError) && (
|
||||
<div className="space-y-1 text-xs text-destructive">
|
||||
{totpError && <p>{totpError}</p>}
|
||||
{qrCodeScanError && <p>{qrCodeScanError}</p>}
|
||||
{destructiveAuthenticatorMessages.length > 0 && (
|
||||
<div
|
||||
id={authenticatorKeyErrorId}
|
||||
className="space-y-1 text-xs text-destructive"
|
||||
>
|
||||
{destructiveAuthenticatorMessages.map(
|
||||
(message, index) => (
|
||||
<p key={`${index}-${message}`}>{message}</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{enterpriseUpgradeError && (
|
||||
<div
|
||||
id={enterpriseUpgradeErrorId}
|
||||
data-testid="enterprise-authenticator-upgrade"
|
||||
className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-sm leading-5 text-foreground"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{enterpriseUpgradeMessage}
|
||||
</span>
|
||||
{enterpriseContactUrl && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="text-muted-foreground">
|
||||
<Link
|
||||
to={enterpriseContactUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-warning underline underline-offset-2"
|
||||
>
|
||||
Contact us
|
||||
</Link>{" "}
|
||||
to enable enterprise authenticator support.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">
|
||||
Works with Google Authenticator, Authy, 1Password, and any
|
||||
standard TOTP authenticator app.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
You need to find the authenticator key from the website
|
||||
where you are using the credential. Here are some guides
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getAuthenticatorSaveError,
|
||||
getCredentialErrorMessage,
|
||||
} from "./authenticatorSaveError";
|
||||
|
||||
function axiosErrorWithDetail(detail: unknown): AxiosError {
|
||||
const error = new AxiosError("Request failed");
|
||||
error.response = {
|
||||
data: { detail },
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
};
|
||||
return error;
|
||||
}
|
||||
|
||||
describe("getAuthenticatorSaveError", () => {
|
||||
it("maps the structured no-code-secret code to actionable code-based setup copy", () => {
|
||||
const result = getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "authenticator_no_code_secret",
|
||||
message: "Push enrollment payloads are not supported.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.code).toBe("no_code_secret");
|
||||
expect(result?.message).toMatch(/code-based setup key/i);
|
||||
expect(result?.message).toMatch(/push-approval/i);
|
||||
expect(result?.message).toMatch(/authenticator app or one-time code/i);
|
||||
});
|
||||
|
||||
it("maps the structured enterprise-required code to enterprise access copy", () => {
|
||||
const result = getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "authenticator_feature_restricted",
|
||||
message: "Enterprise plan required.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.code).toBe("enterprise_required");
|
||||
expect(result?.message).toMatch(/enterprise plan/i);
|
||||
});
|
||||
|
||||
it("preserves the detected vendor id for enterprise-required copy", () => {
|
||||
const result = getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "authenticator_feature_restricted",
|
||||
message: "Enterprise plan required.",
|
||||
vendor: "example",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.code).toBe("enterprise_required");
|
||||
expect(result?.message).toBe(
|
||||
"This authenticator requires a Skyvern enterprise plan.",
|
||||
);
|
||||
expect(result?.vendor).toBe("example");
|
||||
});
|
||||
|
||||
it("maps unsupported TOTP config to the backend message", () => {
|
||||
const result = getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "authenticator_totp_config_unsupported",
|
||||
message: "The authenticator setup code is malformed.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.code).toBe("unsupported_totp_config");
|
||||
expect(result?.message).toBe("The authenticator setup code is malformed.");
|
||||
});
|
||||
|
||||
it("falls back to the backend message for an invalid-key code", () => {
|
||||
const result = getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "invalid_authenticator_key",
|
||||
message: "That is not a valid Base32 secret.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.code).toBe("invalid_authenticator_key");
|
||||
expect(result?.message).toBe("That is not a valid Base32 secret.");
|
||||
});
|
||||
|
||||
it("does not infer unsupported push enrollment from unknown future codes", () => {
|
||||
const result = getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "batch_enrollment_failed",
|
||||
message: "The authenticator setup could not be saved.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result?.code).toBe("unknown");
|
||||
expect(result?.message).toBe("The authenticator setup could not be saved.");
|
||||
});
|
||||
|
||||
it("classifies a legacy string detail that mentions the authenticator key", () => {
|
||||
const result = getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail(
|
||||
"Invalid authenticator key. Paste the raw Base32 setup key.",
|
||||
),
|
||||
);
|
||||
|
||||
expect(result?.code).toBe("invalid_authenticator_key");
|
||||
expect(result?.message).toContain("Invalid authenticator key");
|
||||
});
|
||||
|
||||
it("returns null for a legacy string detail unrelated to the authenticator", () => {
|
||||
expect(
|
||||
getAuthenticatorSaveError(
|
||||
axiosErrorWithDetail("Username and password are required"),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there is no response detail", () => {
|
||||
expect(getAuthenticatorSaveError(new Error("boom"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCredentialErrorMessage", () => {
|
||||
it("returns a legacy string detail verbatim", () => {
|
||||
expect(
|
||||
getCredentialErrorMessage(axiosErrorWithDetail("Something went wrong")),
|
||||
).toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("returns the message field from a structured detail", () => {
|
||||
expect(
|
||||
getCredentialErrorMessage(
|
||||
axiosErrorWithDetail({
|
||||
error_code: "enterprise_required",
|
||||
message: "Enterprise plan required.",
|
||||
}),
|
||||
),
|
||||
).toBe("Enterprise plan required.");
|
||||
});
|
||||
|
||||
it("returns null when no usable detail is present", () => {
|
||||
expect(getCredentialErrorMessage(new Error("boom"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { isAxiosError } from "axios";
|
||||
|
||||
type AuthenticatorErrorCode =
|
||||
| "no_code_secret"
|
||||
| "unsupported_totp_config"
|
||||
| "enterprise_required"
|
||||
| "invalid_authenticator_key"
|
||||
| "unknown";
|
||||
|
||||
type AuthenticatorSaveError = {
|
||||
code: AuthenticatorErrorCode;
|
||||
message: string;
|
||||
vendor?: string;
|
||||
};
|
||||
|
||||
type StructuredErrorDetail = {
|
||||
error_code?: unknown;
|
||||
message?: unknown;
|
||||
vendor?: unknown;
|
||||
};
|
||||
|
||||
const EXACT_ERROR_CODE_MAP: Record<string, AuthenticatorErrorCode> = {
|
||||
authenticator_no_code_secret: "no_code_secret",
|
||||
authenticator_totp_config_unsupported: "unsupported_totp_config",
|
||||
authenticator_feature_restricted: "enterprise_required",
|
||||
invalid_authenticator_key: "invalid_authenticator_key",
|
||||
authenticator_key_required: "invalid_authenticator_key",
|
||||
};
|
||||
|
||||
const NO_CODE_SECRET_MESSAGE =
|
||||
"This QR code doesn't contain a code-based setup key that Skyvern can use. It may enroll a push-approval app or device-bound authenticator. In the site's security settings, choose an authenticator app or one-time code, or use a TOTP setup key instead, then scan that QR code or paste its setup key.";
|
||||
|
||||
const INVALID_KEY_MESSAGE =
|
||||
"Invalid authenticator key. Paste the raw Base32 setup key or full otpauth:// URI from the website's 2FA setup screen.";
|
||||
const UNSUPPORTED_TOTP_CONFIG_MESSAGE =
|
||||
"We recognized this as an authenticator setup QR, but its code configuration is malformed or unsupported. Use a standard setup key or otpauth:// URI instead.";
|
||||
|
||||
function enterpriseMessage(): string {
|
||||
return "This authenticator requires a Skyvern enterprise plan.";
|
||||
}
|
||||
|
||||
function classifyCode(rawCode: string): AuthenticatorErrorCode | null {
|
||||
const code = rawCode.toLowerCase();
|
||||
const exactCode = EXACT_ERROR_CODE_MAP[code];
|
||||
if (exactCode) {
|
||||
return exactCode;
|
||||
}
|
||||
return rawCode ? "unknown" : null;
|
||||
}
|
||||
|
||||
function resolveMessage(
|
||||
code: AuthenticatorErrorCode,
|
||||
backendMessage: string | undefined,
|
||||
): string {
|
||||
switch (code) {
|
||||
case "no_code_secret":
|
||||
return NO_CODE_SECRET_MESSAGE;
|
||||
case "unsupported_totp_config":
|
||||
return backendMessage?.trim() || UNSUPPORTED_TOTP_CONFIG_MESSAGE;
|
||||
case "enterprise_required":
|
||||
return enterpriseMessage();
|
||||
case "invalid_authenticator_key":
|
||||
return backendMessage?.trim() || INVALID_KEY_MESSAGE;
|
||||
case "unknown":
|
||||
return backendMessage?.trim() || INVALID_KEY_MESSAGE;
|
||||
}
|
||||
}
|
||||
|
||||
function fromStructuredDetail(
|
||||
detail: StructuredErrorDetail,
|
||||
): AuthenticatorSaveError | null {
|
||||
const rawCode =
|
||||
typeof detail.error_code === "string" ? detail.error_code : "";
|
||||
const backendMessage =
|
||||
typeof detail.message === "string" ? detail.message : undefined;
|
||||
const vendor = typeof detail.vendor === "string" ? detail.vendor : undefined;
|
||||
const code = classifyCode(rawCode);
|
||||
if (!code) {
|
||||
if (backendMessage && /authenticator/i.test(backendMessage)) {
|
||||
return { code: "unknown", message: backendMessage.trim(), vendor };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code,
|
||||
message: resolveMessage(code, backendMessage),
|
||||
vendor,
|
||||
};
|
||||
}
|
||||
|
||||
function fromStringDetail(detail: string): AuthenticatorSaveError | null {
|
||||
const trimmed = detail.trim();
|
||||
if (!/authenticator key/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
const code: AuthenticatorErrorCode = /invalid/i.test(trimmed)
|
||||
? "invalid_authenticator_key"
|
||||
: "unknown";
|
||||
return { code, message: trimmed };
|
||||
}
|
||||
|
||||
function getErrorDetail(error: unknown): unknown {
|
||||
if (isAxiosError<{ detail?: unknown }>(error)) {
|
||||
return error.response?.data?.detail;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts authenticator-specific user-facing error info from Axios failures, including legacy string details and structured `{ error_code, message, vendor? }` details.
|
||||
* Returns null when the error is not an authenticator setup failure so callers can fall back to generic toast handling.
|
||||
*/
|
||||
function getAuthenticatorSaveError(
|
||||
error: unknown,
|
||||
): AuthenticatorSaveError | null {
|
||||
const detail = getErrorDetail(error);
|
||||
if (typeof detail === "string") {
|
||||
return fromStringDetail(detail);
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
return fromStructuredDetail(detail as StructuredErrorDetail);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a plain user-facing string from an Axios error for toast copy,
|
||||
* normalizing both the legacy string `detail` and the structured object
|
||||
* `detail` shapes. Returns null when no detail is present.
|
||||
*/
|
||||
function getCredentialErrorMessage(error: unknown): string | null {
|
||||
const detail = getErrorDetail(error);
|
||||
if (typeof detail === "string") {
|
||||
return detail.trim() || null;
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
const message = (detail as StructuredErrorDetail).message;
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { getAuthenticatorSaveError, getCredentialErrorMessage };
|
||||
export type { AuthenticatorSaveError, AuthenticatorErrorCode };
|
||||