feat: archive logs export (#38207)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
非法操作
2026-07-14 16:25:37 +08:00
committed by GitHub
parent 82ff93cbdd
commit f09d2b191f
67 changed files with 4779 additions and 18 deletions
+2
View File
@@ -26,6 +26,7 @@ from .rbac import migrate_dataset_permissions_to_rbac, migrate_member_roles_to_r
from .retention import (
archive_workflow_runs,
archive_workflow_runs_plan,
backfill_workflow_run_archive_bundles,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@@ -54,6 +55,7 @@ __all__ = [
"archive_workflow_runs",
"archive_workflow_runs_plan",
"backfill_plugin_auto_upgrade",
"backfill_workflow_run_archive_bundles",
"clean_expired_messages",
"clean_workflow_runs",
"cleanup_orphaned_draft_variables",
+85
View File
@@ -65,6 +65,15 @@ def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
return sorted(set(parsed))
def _parse_comma_separated_ids(raw_ids: str | None, *, param_name: str) -> list[str] | None:
if raw_ids is None:
return None
parsed = sorted({raw_id.strip() for raw_id in raw_ids.split(",") if raw_id.strip()})
if not parsed:
raise click.BadParameter(f"{param_name} must not be empty")
return parsed
def _get_archive_candidate_tenant_ids_by_prefix(
session: Session,
prefix: str,
@@ -725,6 +734,82 @@ def archive_workflow_runs(
)
@click.command(
"backfill-workflow-run-archive-bundles",
help="Backfill workflow-run archive bundle DB index from object-storage manifests.",
)
@click.option("--tenant-ids", default=None, help="Optional comma-separated tenant IDs.")
@click.option(
"--tenant-prefixes",
default=None,
help="Optional comma-separated tenant ID first hex digits, e.g. 0,1,a,f.",
)
@click.option("--year", default=None, type=click.IntRange(min=1, max=9999), help="Optional archive year filter.")
@click.option("--month", default=None, type=click.IntRange(min=1, max=12), help="Optional archive month filter.")
@click.option("--limit", default=None, type=click.IntRange(min=1), help="Maximum number of manifests to process.")
@click.option("--dry-run", is_flag=True, help="Preview without writing workflow_run_archive_bundles.")
def backfill_workflow_run_archive_bundles(
tenant_ids: str | None,
tenant_prefixes: str | None,
year: int | None,
month: int | None,
limit: int | None,
dry_run: bool,
) -> None:
"""
Reconcile `workflow_run_archive_bundles` from V2 archive manifests.
This command is meant for bootstrapping the listing/download index after deploy or repairing index drift. The R2
manifests remain the source of truth; this command only mirrors their query metadata into the database.
"""
from services.retention.workflow_run.archive_bundle_index import WorkflowRunArchiveBundleIndexBackfill
if tenant_ids and tenant_prefixes:
raise click.UsageError("Choose either --tenant-ids or --tenant-prefixes, not both.")
if month is not None and year is None:
raise click.UsageError("--month must be used with --year.")
parsed_tenant_ids = _parse_comma_separated_ids(tenant_ids, param_name="tenant-ids")
parsed_tenant_prefixes = _parse_tenant_prefixes(tenant_prefixes)
if not parsed_tenant_ids and not parsed_tenant_prefixes:
click.echo(
click.style(
"No tenant scope supplied; scanning the full workflow-runs/v2/ archive prefix.",
fg="yellow",
)
)
started_at = datetime.datetime.now(datetime.UTC)
click.echo(click.style(f"Starting archive bundle index backfill at {started_at.isoformat()}.", fg="white"))
backfill = WorkflowRunArchiveBundleIndexBackfill()
summary = backfill.run(
tenant_ids=parsed_tenant_ids,
tenant_prefixes=parsed_tenant_prefixes or None,
year=year,
month=month,
limit=limit,
dry_run=dry_run,
)
status = "completed with failures" if summary.bundles_failed else "completed successfully"
fg = "red" if summary.bundles_failed else "green"
action = "would_upsert" if dry_run else "upserted"
action_count = summary.bundles_processed if dry_run else summary.bundles_upserted
click.echo(
click.style(
f"Backfill {status}. manifests_found={summary.manifests_found} "
f"bundles_processed={summary.bundles_processed} {action}={action_count} "
f"bundles_failed={summary.bundles_failed} runs={summary.workflow_run_count} rows={summary.row_count} "
f"archive_bytes={summary.archive_bytes} duration={summary.elapsed_time:.2f}s",
fg=fg,
)
)
for error in summary.errors[:10]:
click.echo(click.style(f" failed {error}", fg="red"))
if len(summary.errors) > 10:
click.echo(click.style(f" ... and {len(summary.errors) - 10} more failures", fg="red"))
def _echo_bundle_archive_operation_summary(summary) -> None:
status = "completed successfully" if summary.bundles_failed == 0 else "completed with failures"
fg = "green" if summary.bundles_failed == 0 else "red"
+2
View File
@@ -43,6 +43,7 @@ from . import (
setup,
spec,
version,
workflow_run_archive,
)
from .agent import composer as agent_composer
from .agent import roster as agent_roster
@@ -238,6 +239,7 @@ __all__ = [
"workflow_draft_variable",
"workflow_node_output_inspector",
"workflow_run",
"workflow_run_archive",
"workflow_statistic",
"workflow_trigger",
"workspace",
@@ -0,0 +1,236 @@
import datetime
from http import HTTPStatus
from flask import redirect
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Conflict, NotFound
from controllers.common.fields import RedirectResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
cloud_edition_billing_enabled,
cloud_edition_billing_paid_plan_required,
is_admin_or_owner_required,
only_edition_cloud,
rbac_permission_required,
setup_required,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.archive_storage import get_export_storage
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from services.retention.workflow_run.archive_download_preparation import ARCHIVE_DOWNLOAD_MIME_TYPE
from services.retention.workflow_run.archive_download_task_cache import (
WorkflowRunArchiveDownloadStatus,
)
from services.retention.workflow_run.archive_log_service import (
WorkflowRunArchiveDownloadNotReadyError,
WorkflowRunArchiveDownloadTaskNotFoundError,
WorkflowRunArchiveNotFoundError,
create_workflow_run_archive_download_task,
get_ready_workflow_run_archive_download_task,
get_workflow_run_archive_download_task,
list_workflow_run_archives,
)
class WorkflowRunArchiveDownloadPayload(BaseModel):
"""Request body for preparing one monthly workflow-run archive download."""
year: int = Field(ge=1)
month: int = Field(ge=1, le=12)
class WorkflowRunArchiveSummaryResponse(ResponseModel):
archived_month_count: int
workflow_run_count: int
archive_bytes: int
latest_archived_at: datetime.datetime | None = None
class WorkflowRunArchiveDownloadTaskResponse(ResponseModel):
download_id: str
year: int
month: int
bundle_count: int
archive_bytes: int
status: WorkflowRunArchiveDownloadStatus
file_name: str | None = None
file_size_bytes: int | None = None
error: str | None = None
created_at: datetime.datetime
updated_at: datetime.datetime
expires_at: datetime.datetime
started_at: datetime.datetime | None = None
finished_at: datetime.datetime | None = None
class WorkflowRunArchiveMonthResponse(ResponseModel):
year: int
month: int
bundle_count: int
workflow_run_count: int
row_count: int
archive_bytes: int
latest_archived_at: datetime.datetime
download_task: WorkflowRunArchiveDownloadTaskResponse | None = None
class WorkflowRunArchiveListResponse(ResponseModel):
summary: WorkflowRunArchiveSummaryResponse
months: list[WorkflowRunArchiveMonthResponse]
register_schema_models(console_ns, WorkflowRunArchiveDownloadPayload)
register_response_schema_models(
console_ns,
WorkflowRunArchiveSummaryResponse,
WorkflowRunArchiveMonthResponse,
WorkflowRunArchiveListResponse,
WorkflowRunArchiveDownloadTaskResponse,
RedirectResponse,
)
def _current_ids() -> tuple[str, str]:
"""Return current `(tenant_id, account_id)` or raise when no workspace is selected."""
current_user, current_tenant_id = current_account_with_tenant()
if not current_tenant_id:
raise NotFound("Current workspace not found")
return current_tenant_id, current_user.id
def _presigned_url_expires_in(expires_at: datetime.datetime) -> int:
"""Keep the storage URL no longer-lived than the Redis task and cap it for browser downloads."""
expires_at_utc = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=datetime.UTC)
remaining_seconds = int((expires_at_utc - datetime.datetime.now(datetime.UTC)).total_seconds())
return max(1, min(3600, remaining_seconds))
@console_ns.route("/workflow-run-archives")
class WorkflowRunArchivesApi(Resource):
@console_ns.doc("list_workflow_run_archives")
@console_ns.doc(description="List monthly workflow-run archive metadata for the current workspace")
@console_ns.response(200, "Success", console_ns.models[WorkflowRunArchiveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self):
tenant_id, _ = _current_ids()
return dump_response(WorkflowRunArchiveListResponse, list_workflow_run_archives(db.session(), tenant_id))
@console_ns.route("/workflow-run-archives/downloads")
class WorkflowRunArchiveDownloadsApi(Resource):
@console_ns.doc("create_workflow_run_archive_download")
@console_ns.doc(description="Create or return a temporary workflow-run archive download task")
@console_ns.expect(console_ns.models[WorkflowRunArchiveDownloadPayload.__name__])
@console_ns.response(
HTTPStatus.ACCEPTED,
"Download task accepted",
console_ns.models[WorkflowRunArchiveDownloadTaskResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def post(self):
tenant_id, account_id = _current_ids()
payload = WorkflowRunArchiveDownloadPayload.model_validate(console_ns.payload or {})
try:
task = create_workflow_run_archive_download_task(
db.session(),
tenant_id=tenant_id,
requested_by=account_id,
year=payload.year,
month=payload.month,
)
except WorkflowRunArchiveNotFoundError as exc:
raise NotFound(str(exc)) from exc
return dump_response(WorkflowRunArchiveDownloadTaskResponse, task), HTTPStatus.ACCEPTED
@console_ns.route("/workflow-run-archives/downloads/<string:download_id>")
class WorkflowRunArchiveDownloadApi(Resource):
@console_ns.doc("get_workflow_run_archive_download")
@console_ns.doc(description="Get a temporary workflow-run archive download task")
@console_ns.response(200, "Success", console_ns.models[WorkflowRunArchiveDownloadTaskResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self, download_id: str):
tenant_id, _ = _current_ids()
try:
task = get_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
raise NotFound(str(exc)) from exc
return dump_response(WorkflowRunArchiveDownloadTaskResponse, task)
@console_ns.route("/workflow-run-archives/downloads/<string:download_id>/file")
class WorkflowRunArchiveDownloadFileApi(Resource):
@console_ns.doc("download_workflow_run_archive_file")
@console_ns.doc(description="Redirect to a prepared workflow-run archive ZIP file")
@console_ns.response(
302,
"Redirect to pre-signed archive storage URL",
console_ns.models[RedirectResponse.__name__],
)
@console_ns.response(409, "Download task is not ready")
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self, download_id: str):
tenant_id, _ = _current_ids()
try:
task = get_ready_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
raise NotFound(str(exc)) from exc
except WorkflowRunArchiveDownloadNotReadyError as exc:
raise Conflict(str(exc)) from exc
storage_key = task.storage_key
if storage_key is None:
raise Conflict(f"Workflow run archive download is not ready: {download_id}")
storage = get_export_storage()
presigned_url = storage.generate_presigned_url(
storage_key,
expires_in=_presigned_url_expires_in(task.expires_at),
filename=task.file_name,
content_type=ARCHIVE_DOWNLOAD_MIME_TYPE,
)
return redirect(presigned_url, code=HTTPStatus.FOUND)
+16
View File
@@ -28,6 +28,7 @@ from models import Account
from models.account import AccountStatus
from models.dataset import RateLimitLog
from models.model import DifySetup
from services.billing_service import BillingService
from services.feature_service import FeatureService, LicenseStatus
from services.operation_service import OperationService, UtmInfo
@@ -168,6 +169,21 @@ def cloud_edition_billing_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R
return decorated
def cloud_edition_billing_paid_plan_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_, current_tenant_id = current_account_with_tenant()
billing_info = BillingService.get_info(current_tenant_id, exclude_vector_space=True)
if not billing_info["enabled"] or billing_info["subscription"]["plan"] not in (
CloudPlan.PROFESSIONAL,
CloudPlan.TEAM,
):
abort(403, "This feature requires a paid plan.")
return view(*args, **kwargs)
return decorated
def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
def interceptor(view: Callable[P, R]):
@wraps(view)
+1
View File
@@ -157,6 +157,7 @@ def init_app(app: DifyApp) -> Celery:
"tasks.regenerate_summary_index_task", # summary index regeneration
"tasks.initialize_created_app_rbac_access_task", # app access initialization
"tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume
"tasks.workflow_run_archive_download_tasks", # workflow-run archive download preparation
]
day = dify_config.CELERY_BEAT_SCHEDULER_TIME
+2
View File
@@ -7,6 +7,7 @@ def init_app(app: DifyApp):
archive_workflow_runs,
archive_workflow_runs_plan,
backfill_plugin_auto_upgrade,
backfill_workflow_run_archive_bundles,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@@ -77,6 +78,7 @@ def init_app(app: DifyApp):
install_rag_pipeline_plugins,
archive_workflow_runs_plan,
archive_workflow_runs,
backfill_workflow_run_archive_bundles,
delete_archived_workflow_runs,
restore_workflow_runs,
clean_workflow_runs,
+17 -2
View File
@@ -11,6 +11,7 @@ import hashlib
import logging
from collections.abc import Generator
from typing import Any, cast
from urllib.parse import quote
import boto3
import orjson
@@ -197,13 +198,22 @@ class ArchiveStorage:
except ClientError as e:
raise ArchiveStorageError(f"Failed to delete object '{key}': {e}")
def generate_presigned_url(self, key: str, expires_in: int = 3600) -> str:
def generate_presigned_url(
self,
key: str,
expires_in: int = 3600,
*,
filename: str | None = None,
content_type: str | None = None,
) -> str:
"""
Generate a pre-signed URL for downloading an object.
Args:
key: Object key (path) within the bucket
expires_in: URL validity duration in seconds (default: 1 hour)
filename: Optional browser download filename
content_type: Optional response content type
Returns:
Pre-signed URL string.
@@ -211,10 +221,15 @@ class ArchiveStorage:
Raises:
ArchiveStorageError: If generation fails
"""
params = {"Bucket": self.bucket, "Key": key}
if filename:
params["ResponseContentDisposition"] = f"attachment; filename*=UTF-8''{quote(filename)}"
if content_type:
params["ResponseContentType"] = content_type
try:
return self.client.generate_presigned_url(
ClientMethod="get_object",
Params={"Bucket": self.bucket, "Key": key},
Params=params,
ExpiresIn=expires_in,
)
except ClientError as e:
+1
View File
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
CSRF_WHITE_LIST = [
re.compile(r"/console/api/apps/[a-f0-9-]+/workflows/draft"),
re.compile(r"/console/api/workflow-run-archives/downloads/[a-f0-9]+/file"),
]
@@ -0,0 +1,61 @@
"""add workflow run archive bundle index table
Revision ID: 7a1c2d9e4b60
Revises: c3d4e5f6a7b8
Create Date: 2026-06-25 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "7a1c2d9e4b60"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def _uuid_column(name: str, **kwargs):
if op.get_bind().dialect.name == "postgresql":
kwargs.setdefault("server_default", sa.text("uuidv7()"))
return sa.Column(name, models.types.StringUUID(), **kwargs)
def upgrade() -> None:
op.create_table(
"workflow_run_archive_bundles",
_uuid_column("id", nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("year", sa.Integer(), nullable=False),
sa.Column("month", sa.Integer(), nullable=False),
sa.Column("shard", sa.String(length=32), nullable=False),
sa.Column("bundle_id", sa.String(length=64), nullable=False),
sa.Column("workflow_run_count", sa.Integer(), nullable=False),
sa.Column("row_count", sa.BigInteger(), nullable=False),
sa.Column("archive_bytes", sa.BigInteger(), nullable=False),
sa.Column("archived_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id", name="workflow_run_archive_bundle_pkey"),
sa.UniqueConstraint(
"tenant_id",
"year",
"month",
"shard",
"bundle_id",
name="workflow_run_archive_bundle_identity_uq",
),
)
op.create_index(
"workflow_run_archive_bundle_tenant_month_idx",
"workflow_run_archive_bundles",
["tenant_id", "year", "month"],
)
def downgrade() -> None:
op.drop_index("workflow_run_archive_bundle_tenant_month_idx", table_name="workflow_run_archive_bundles")
op.drop_table("workflow_run_archive_bundles")
+2
View File
@@ -144,6 +144,7 @@ from .workflow import (
WorkflowNodeExecutionTriggeredFrom,
WorkflowPause,
WorkflowRun,
WorkflowRunArchiveBundle,
WorkflowType,
resolve_workflow_kind,
)
@@ -282,6 +283,7 @@ __all__ = [
"WorkflowNodeExecutionTriggeredFrom",
"WorkflowPause",
"WorkflowRun",
"WorkflowRunArchiveBundle",
"WorkflowRunTriggeredFrom",
"WorkflowSchedulePlan",
"WorkflowToolProvider",
+34
View File
@@ -1446,6 +1446,40 @@ class WorkflowArchiveLog(TypeBase):
}
class WorkflowRunArchiveBundle(DefaultFieldsDCMixin, TypeBase):
"""
Query index for one immutable V2 workflow-run archive bundle.
R2 manifest objects remain the recoverable archive source of truth. This table stores the small subset needed to
list tenant/month archives and locate bundles without listing object storage online. Missing rows can be rebuilt
from existing manifests by a backfill/reconciliation command.
"""
__tablename__ = "workflow_run_archive_bundles"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="workflow_run_archive_bundle_pkey"),
sa.UniqueConstraint(
"tenant_id",
"year",
"month",
"shard",
"bundle_id",
name="workflow_run_archive_bundle_identity_uq",
),
sa.Index("workflow_run_archive_bundle_tenant_month_idx", "tenant_id", "year", "month"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
year: Mapped[int] = mapped_column(sa.Integer, nullable=False)
month: Mapped[int] = mapped_column(sa.Integer, nullable=False)
shard: Mapped[str] = mapped_column(String(32), nullable=False)
bundle_id: Mapped[str] = mapped_column(String(64), nullable=False)
workflow_run_count: Mapped[int] = mapped_column(sa.Integer, nullable=False)
row_count: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
archive_bytes: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
archived_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class ConversationVariable(TypeBase):
__tablename__ = "workflow_conversation_variables"
+120
View File
@@ -9721,6 +9721,61 @@ Suggest example workflow-generator instructions for the tenant
| 200 | Suggestions generated successfully | **application/json**: [WorkflowInstructionSuggestionsResponse](#workflowinstructionsuggestionsresponse)<br> |
| 400 | Invalid request parameters | |
### [GET] /workflow-run-archives
List monthly workflow-run archive metadata for the current workspace
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkflowRunArchiveListResponse](#workflowrunarchivelistresponse)<br> |
### [POST] /workflow-run-archives/downloads
Create or return a temporary workflow-run archive download task
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [WorkflowRunArchiveDownloadPayload](#workflowrunarchivedownloadpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 202 | Download task accepted | **application/json**: [WorkflowRunArchiveDownloadTaskResponse](#workflowrunarchivedownloadtaskresponse)<br> |
### [GET] /workflow-run-archives/downloads/{download_id}
Get a temporary workflow-run archive download task
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| download_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkflowRunArchiveDownloadTaskResponse](#workflowrunarchivedownloadtaskresponse)<br> |
### [GET] /workflow-run-archives/downloads/{download_id}/file
Redirect to a prepared workflow-run archive ZIP file
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| download_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 302 | Redirect to pre-signed archive storage URL | **application/json**: [RedirectResponse](#redirectresponse)<br> |
| 409 | Download task is not ready | |
### [GET] /workflow/{workflow_run_id}/events
**Get workflow execution events stream after resume**
@@ -23495,6 +23550,71 @@ tenant's default model. The underlying generator never raises — an empty
| result | string | | Yes |
| updated_at | integer | | Yes |
#### WorkflowRunArchiveDownloadPayload
Request body for preparing one monthly workflow-run archive download.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| month | integer | | Yes |
| year | integer | | Yes |
#### WorkflowRunArchiveDownloadStatus
Lifecycle state for an asynchronous archive download request.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkflowRunArchiveDownloadStatus | string | Lifecycle state for an asynchronous archive download request. | |
#### WorkflowRunArchiveDownloadTaskResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_bytes | integer | | Yes |
| bundle_count | integer | | Yes |
| created_at | dateTime | | Yes |
| download_id | string | | Yes |
| error | string | | No |
| expires_at | dateTime | | Yes |
| file_name | string | | No |
| file_size_bytes | integer | | No |
| finished_at | string | | No |
| month | integer | | Yes |
| started_at | string | | No |
| status | [WorkflowRunArchiveDownloadStatus](#workflowrunarchivedownloadstatus) | | Yes |
| updated_at | dateTime | | Yes |
| year | integer | | Yes |
#### WorkflowRunArchiveListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| months | [ [WorkflowRunArchiveMonthResponse](#workflowrunarchivemonthresponse) ] | | Yes |
| summary | [WorkflowRunArchiveSummaryResponse](#workflowrunarchivesummaryresponse) | | Yes |
#### WorkflowRunArchiveMonthResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_bytes | integer | | Yes |
| bundle_count | integer | | Yes |
| download_task | [WorkflowRunArchiveDownloadTaskResponse](#workflowrunarchivedownloadtaskresponse) | | No |
| latest_archived_at | dateTime | | Yes |
| month | integer | | Yes |
| row_count | integer | | Yes |
| workflow_run_count | integer | | Yes |
| year | integer | | Yes |
#### WorkflowRunArchiveSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_bytes | integer | | Yes |
| archived_month_count | integer | | Yes |
| latest_archived_at | string | | No |
| workflow_run_count | integer | | Yes |
#### WorkflowRunCountQuery
| Name | Type | Description | Required |
@@ -0,0 +1,347 @@
"""
Workflow-run archive bundle index helpers.
Archive manifests in object storage remain the recoverable source of truth. This module mirrors their small query
surface into `workflow_run_archive_bundles` so console listing and download jobs can avoid listing R2 on request.
The backfill path is intentionally idempotent: every manifest is decoded, checked against the V2 schema markers, and
upserted by immutable bundle identity.
"""
import datetime
import json
import logging
import time
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TypedDict, cast
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from extensions.ext_database import db
from libs.archive_storage import ArchiveStorage, get_archive_storage
from models.workflow import WorkflowRunArchiveBundle
from services.retention.workflow_run.constants import (
ARCHIVE_BUNDLE_FORMAT,
ARCHIVE_BUNDLE_MANIFEST_NAME,
ARCHIVE_BUNDLE_SCHEMA_VERSION,
)
logger = logging.getLogger(__name__)
ARCHIVE_BUNDLE_ROOT_PREFIX = "workflow-runs/v2/"
class ArchiveBundleTableManifestEntry(TypedDict):
row_count: int
checksum: str
size_bytes: int
object_key: str
class ArchiveBundleManifest(TypedDict):
schema_version: str
archive_format: str
tenant_id: str
tenant_prefix: str
year: int
month: int
shard: str
bundle_id: str
object_prefix: str
workflow_run_count: int
workflow_node_execution_count: int
min_created_at: str
max_created_at: str
min_run_id: str
max_run_id: str
archived_at: str
tables: dict[str, ArchiveBundleTableManifestEntry]
run_ids: list[str]
@dataclass(frozen=True)
class ArchiveBundleIndexValues:
"""Computed DB-index values derived from one manifest."""
row_count: int
archive_bytes: int
archived_at: datetime.datetime
@dataclass
class ArchiveBundleIndexBackfillSummary:
"""Aggregate result for a manifest-to-DB-index reconciliation run."""
manifests_found: int = 0
bundles_processed: int = 0
bundles_upserted: int = 0
bundles_failed: int = 0
workflow_run_count: int = 0
row_count: int = 0
archive_bytes: int = 0
elapsed_time: float = 0.0
errors: list[str] = field(default_factory=list)
def decode_archive_bundle_manifest(manifest_data: bytes) -> ArchiveBundleManifest:
"""Decode raw manifest bytes into the V2 archive manifest shape."""
return cast(ArchiveBundleManifest, json.loads(manifest_data.decode("utf-8")))
def parse_archive_manifest_datetime(value: str) -> datetime.datetime:
"""Parse manifest datetimes and normalize timezone-aware values to naive UTC for DB storage."""
parsed = datetime.datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed
return parsed.astimezone(datetime.UTC).replace(tzinfo=None)
def calculate_archive_bundle_index_values(
manifest: ArchiveBundleManifest,
manifest_size_bytes: int,
) -> ArchiveBundleIndexValues:
"""Calculate row count, stored bytes, and archived timestamp for the DB index."""
_validate_archive_bundle_manifest(manifest)
row_count = sum(entry["row_count"] for entry in manifest["tables"].values())
archive_bytes = manifest_size_bytes + sum(entry["size_bytes"] for entry in manifest["tables"].values())
return ArchiveBundleIndexValues(
row_count=row_count,
archive_bytes=archive_bytes,
archived_at=parse_archive_manifest_datetime(manifest["archived_at"]),
)
def upsert_archive_bundle_index_from_manifest(
session: Session,
manifest: ArchiveBundleManifest,
manifest_size_bytes: int,
) -> WorkflowRunArchiveBundle:
"""
Persist one archive manifest into `workflow_run_archive_bundles`.
The caller owns transaction boundaries. Re-running this function for the same manifest is safe and refreshes the
mutable metrics derived from object sizes and row counts.
"""
values = calculate_archive_bundle_index_values(manifest, manifest_size_bytes)
existing = session.scalar(
select(WorkflowRunArchiveBundle).where(
WorkflowRunArchiveBundle.tenant_id == manifest["tenant_id"],
WorkflowRunArchiveBundle.year == manifest["year"],
WorkflowRunArchiveBundle.month == manifest["month"],
WorkflowRunArchiveBundle.shard == manifest["shard"],
WorkflowRunArchiveBundle.bundle_id == manifest["bundle_id"],
)
)
if existing is None:
bundle = WorkflowRunArchiveBundle(
tenant_id=manifest["tenant_id"],
year=manifest["year"],
month=manifest["month"],
shard=manifest["shard"],
bundle_id=manifest["bundle_id"],
workflow_run_count=manifest["workflow_run_count"],
row_count=values.row_count,
archive_bytes=values.archive_bytes,
archived_at=values.archived_at,
)
session.add(bundle)
return bundle
existing.workflow_run_count = manifest["workflow_run_count"]
existing.row_count = values.row_count
existing.archive_bytes = values.archive_bytes
existing.archived_at = values.archived_at
return existing
class WorkflowRunArchiveBundleIndexBackfill:
"""
Rebuild the DB bundle index by scanning object-store manifests.
Tenant IDs are the cheapest scope because they map directly to the object prefix. Tenant prefixes are supported for
rollout reconciliation, but they still require listing all tenants under that prefix and filtering keys locally.
"""
storage: ArchiveStorage | None
session_factory: sessionmaker[Session]
def __init__(
self,
*,
storage: ArchiveStorage | None = None,
session_factory: sessionmaker[Session] | None = None,
) -> None:
self.storage = storage
self.session_factory = session_factory or sessionmaker(bind=db.engine, expire_on_commit=False)
def run(
self,
*,
tenant_ids: Sequence[str] | None = None,
tenant_prefixes: Sequence[str] | None = None,
year: int | None = None,
month: int | None = None,
limit: int | None = None,
dry_run: bool = False,
) -> ArchiveBundleIndexBackfillSummary:
"""Scan matching manifest objects and idempotently upsert their DB index rows."""
start_time = time.time()
summary = ArchiveBundleIndexBackfillSummary()
storage = self.storage or get_archive_storage()
manifest_keys = self._list_manifest_keys(
storage,
tenant_ids=tenant_ids,
tenant_prefixes=tenant_prefixes,
year=year,
month=month,
)
summary.manifests_found = len(manifest_keys)
if limit is not None:
manifest_keys = manifest_keys[:limit]
for manifest_key in manifest_keys:
try:
manifest_data = storage.get_object(manifest_key)
manifest = decode_archive_bundle_manifest(manifest_data)
self._validate_manifest_scope(
manifest,
manifest_key=manifest_key,
tenant_ids=tenant_ids,
tenant_prefixes=tenant_prefixes,
year=year,
month=month,
)
values = calculate_archive_bundle_index_values(manifest, len(manifest_data))
summary.bundles_processed += 1
summary.workflow_run_count += manifest["workflow_run_count"]
summary.row_count += values.row_count
summary.archive_bytes += values.archive_bytes
if dry_run:
continue
with self.session_factory() as session:
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
session.commit()
summary.bundles_upserted += 1
except Exception as exc:
logger.warning("Failed to backfill workflow archive bundle index from %s", manifest_key, exc_info=True)
summary.bundles_failed += 1
summary.errors.append(f"{manifest_key}: {exc}")
summary.elapsed_time = time.time() - start_time
return summary
@classmethod
def _list_manifest_keys(
cls,
storage: ArchiveStorage,
*,
tenant_ids: Sequence[str] | None,
tenant_prefixes: Sequence[str] | None,
year: int | None,
month: int | None,
) -> list[str]:
prefixes = cls._list_prefixes(tenant_ids=tenant_ids, tenant_prefixes=tenant_prefixes, year=year, month=month)
keys: list[str] = []
for prefix in prefixes:
keys.extend(storage.list_objects(prefix))
return sorted(
key
for key in keys
if key.endswith(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
and cls._manifest_key_matches_scope(
key,
tenant_ids=tenant_ids,
tenant_prefixes=tenant_prefixes,
year=year,
month=month,
)
)
@staticmethod
def _list_prefixes(
*,
tenant_ids: Sequence[str] | None,
tenant_prefixes: Sequence[str] | None,
year: int | None,
month: int | None,
) -> list[str]:
if tenant_ids:
prefixes = []
for tenant_id in sorted(set(tenant_ids)):
prefix = f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix={tenant_id[0].lower()}/tenant_id={tenant_id}/"
if year is not None:
prefix += f"year={year:04d}/"
if month is not None:
prefix += f"month={month:02d}/"
prefixes.append(prefix)
return prefixes
if tenant_prefixes:
return [
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix={tenant_prefix}/"
for tenant_prefix in sorted(set(tenant_prefixes))
]
return [ARCHIVE_BUNDLE_ROOT_PREFIX]
@staticmethod
def _manifest_key_matches_scope(
key: str,
*,
tenant_ids: Sequence[str] | None,
tenant_prefixes: Sequence[str] | None,
year: int | None,
month: int | None,
) -> bool:
if tenant_ids and _extract_key_part(key, "tenant_id") not in set(tenant_ids):
return False
if tenant_prefixes and _extract_key_part(key, "tenant_prefix") not in set(tenant_prefixes):
return False
if year is not None and _extract_key_part(key, "year") != f"{year:04d}":
return False
if month is not None and _extract_key_part(key, "month") != f"{month:02d}":
return False
return True
@staticmethod
def _validate_manifest_scope(
manifest: ArchiveBundleManifest,
*,
manifest_key: str,
tenant_ids: Sequence[str] | None,
tenant_prefixes: Sequence[str] | None,
year: int | None,
month: int | None,
) -> None:
expected_object_prefix = manifest_key.removesuffix(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
if manifest["object_prefix"] != expected_object_prefix:
raise ValueError(
f"manifest object_prefix mismatch: expected={expected_object_prefix}, "
f"actual={manifest['object_prefix']}"
)
if tenant_ids and manifest["tenant_id"] not in tenant_ids:
raise ValueError(f"manifest tenant_id is outside requested scope: {manifest['tenant_id']}")
if tenant_prefixes and manifest["tenant_prefix"] not in tenant_prefixes:
raise ValueError(f"manifest tenant_prefix is outside requested scope: {manifest['tenant_prefix']}")
if year is not None and manifest["year"] != year:
raise ValueError(f"manifest year is outside requested scope: {manifest['year']}")
if month is not None and manifest["month"] != month:
raise ValueError(f"manifest month is outside requested scope: {manifest['month']}")
def _validate_archive_bundle_manifest(manifest: ArchiveBundleManifest) -> None:
if manifest["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
raise ValueError(f"unsupported archive bundle schema version: {manifest['schema_version']}")
if manifest["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
raise ValueError(f"unsupported archive bundle format: {manifest['archive_format']}")
def _extract_key_part(key: str, name: str) -> str | None:
prefix = f"{name}="
for part in key.split("/"):
if part.startswith(prefix):
return part[len(prefix) :]
return None
@@ -0,0 +1,354 @@
"""
Prepare monthly workflow-run archive downloads.
Console requests create a short-lived Redis task and Celery runs this module in the background. The DB bundle index is
the online lookup source: this preparer never lists archive storage, and it validates the indexed bundle set against the
stable download id before packaging archive Parquet objects into one user-facing CSV ZIP file.
"""
import datetime
import hashlib
import io
import logging
import re
import zipfile
from collections.abc import Sequence
from typing import cast
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.csv as pa_csv
import pyarrow.parquet as pq
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.helper.csv_sanitizer import CSVSanitizer
from extensions.ext_database import db
from libs.archive_storage import ArchiveStorage, get_archive_storage, get_export_storage
from models.workflow import WorkflowRunArchiveBundle
from services.retention.workflow_run.archive_bundle_index import (
ARCHIVE_BUNDLE_ROOT_PREFIX,
ArchiveBundleManifest,
ArchiveBundleTableManifestEntry,
decode_archive_bundle_manifest,
)
from services.retention.workflow_run.archive_download_task_cache import (
WorkflowRunArchiveDownloadStatus,
WorkflowRunArchiveDownloadTask,
WorkflowRunArchiveDownloadTaskCache,
build_archive_download_id,
)
from services.retention.workflow_run.constants import (
ARCHIVE_BUNDLE_FORMAT,
ARCHIVE_BUNDLE_MANIFEST_NAME,
ARCHIVE_BUNDLE_SCHEMA_VERSION,
)
logger = logging.getLogger(__name__)
ARCHIVE_DOWNLOAD_ROOT_PREFIX = "workflow-runs/downloads/v1/"
ARCHIVE_DOWNLOAD_MIME_TYPE = "application/zip"
_CSV_FORMULA_PREFIX_PATTERN = f"^[{re.escape(''.join(CSVSanitizer.FORMULA_CHARS))}]"
class WorkflowRunArchiveDownloadPreparer:
"""
Build one ready-to-download CSV ZIP for a Redis archive download task.
The output object is deterministic for a given `download_id`, so retrying a failed task overwrites the same
temporary object instead of creating unbounded duplicate files. Source archive bundles are read from the archive
bucket, while the prepared ZIP is written to the export bucket so object lifecycle policies can expire downloads
without touching long-lived archives.
"""
archive_storage: ArchiveStorage | None
download_storage: ArchiveStorage | None
cache: WorkflowRunArchiveDownloadTaskCache
session_factory: sessionmaker[Session]
def __init__(
self,
*,
storage: ArchiveStorage | None = None,
archive_storage: ArchiveStorage | None = None,
download_storage: ArchiveStorage | None = None,
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
session_factory: sessionmaker[Session] | None = None,
) -> None:
self.archive_storage = archive_storage or storage
self.download_storage = download_storage or storage
self.cache = cache or WorkflowRunArchiveDownloadTaskCache()
self.session_factory = session_factory or sessionmaker(bind=db.engine, expire_on_commit=False)
def prepare(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
"""Prepare a ZIP for an existing Redis task and persist terminal task state."""
with self.cache.lock(tenant_id=tenant_id, download_id=download_id):
task = self.cache.get(tenant_id=tenant_id, download_id=download_id)
if task is None:
logger.info("Workflow run archive download task expired before preparation: %s", download_id)
return None
if task.status != WorkflowRunArchiveDownloadStatus.PENDING:
logger.info("Skipping workflow run archive download task in %s state: %s", task.status, download_id)
return task
processing_task = self._mark_processing(task)
try:
archive_storage = self.archive_storage or get_archive_storage()
download_storage = self.download_storage or get_export_storage()
bundles = self._get_task_bundles(processing_task)
payload = self._build_zip_payload(archive_storage, processing_task, bundles)
storage_key = build_archive_download_storage_key(processing_task)
download_storage.put_object(storage_key, payload)
return self._mark_ready(processing_task, storage_key=storage_key, file_size_bytes=len(payload))
except Exception as exc:
logger.exception("Failed to prepare workflow run archive download %s", download_id)
return self._mark_failed(processing_task, error=str(exc))
def _get_task_bundles(self, task: WorkflowRunArchiveDownloadTask) -> list[WorkflowRunArchiveBundle]:
with self.session_factory() as session:
return _list_task_bundles(session, task)
def _build_zip_payload(
self,
storage: ArchiveStorage,
task: WorkflowRunArchiveDownloadTask,
bundles: Sequence[WorkflowRunArchiveBundle],
) -> bytes:
zip_root = f"workflow-run-logs-{task.year:04d}-{task.month:02d}"
csv_buffers: dict[str, io.BytesIO] = {}
csv_headers_written: set[str] = set()
for bundle in bundles:
object_prefix = _build_archive_bundle_object_prefix(task, bundle)
_, manifest = _load_and_validate_manifest(storage, task, bundle, object_prefix)
for table_name in sorted(manifest["tables"]):
entry = manifest["tables"][table_name]
object_key = entry["object_key"]
table_payload = storage.get_object(object_key)
_validate_table_payload(object_key=object_key, entry=entry, payload=table_payload)
csv_payload = _parquet_payload_to_csv(
table_payload,
include_header=table_name not in csv_headers_written,
)
if not csv_payload:
continue
csv_buffers.setdefault(table_name, io.BytesIO()).write(csv_payload)
csv_headers_written.add(table_name)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for table_name, csv_buffer in sorted(csv_buffers.items()):
archive.writestr(f"{zip_root}/{table_name}.csv", csv_buffer.getvalue())
return buffer.getvalue()
def _mark_processing(self, task: WorkflowRunArchiveDownloadTask) -> WorkflowRunArchiveDownloadTask:
now = datetime.datetime.now(datetime.UTC)
processing_task = task.model_copy(
update={
"status": WorkflowRunArchiveDownloadStatus.PROCESSING,
"error": None,
"updated_at": now,
"started_at": task.started_at or now,
}
)
self.cache.save(processing_task)
return processing_task
def _mark_ready(
self,
task: WorkflowRunArchiveDownloadTask,
*,
storage_key: str,
file_size_bytes: int,
) -> WorkflowRunArchiveDownloadTask:
now = datetime.datetime.now(datetime.UTC)
ready_task = task.model_copy(
update={
"status": WorkflowRunArchiveDownloadStatus.READY,
"file_name": build_archive_download_file_name(task),
"storage_key": storage_key,
"file_size_bytes": file_size_bytes,
"error": None,
"updated_at": now,
"finished_at": now,
}
)
return self._save_terminal_task(task, ready_task)
def _mark_failed(self, task: WorkflowRunArchiveDownloadTask, *, error: str) -> WorkflowRunArchiveDownloadTask:
now = datetime.datetime.now(datetime.UTC)
failed_task = task.model_copy(
update={
"status": WorkflowRunArchiveDownloadStatus.FAILED,
"error": error,
"updated_at": now,
"finished_at": now,
}
)
return self._save_terminal_task(task, failed_task)
def _save_terminal_task(
self,
processing_task: WorkflowRunArchiveDownloadTask,
terminal_task: WorkflowRunArchiveDownloadTask,
) -> WorkflowRunArchiveDownloadTask:
with self.cache.lock(tenant_id=processing_task.tenant_id, download_id=processing_task.download_id):
current = self.cache.get(
tenant_id=processing_task.tenant_id,
download_id=processing_task.download_id,
)
if (
current is None
or current.status != WorkflowRunArchiveDownloadStatus.PROCESSING
or current.celery_task_id != processing_task.celery_task_id
):
return current or terminal_task
self.cache.save(terminal_task)
return terminal_task
def build_archive_download_file_name(task: WorkflowRunArchiveDownloadTask) -> str:
"""Return the browser download filename for one monthly archive."""
return f"workflow-run-logs-{task.year:04d}-{task.month:02d}.zip"
def build_archive_download_storage_key(task: WorkflowRunArchiveDownloadTask) -> str:
"""Return the deterministic object-store key for a prepared download ZIP."""
return (
f"{ARCHIVE_DOWNLOAD_ROOT_PREFIX}tenant_prefix={task.tenant_id[0].lower()}/tenant_id={task.tenant_id}/"
f"year={task.year:04d}/month={task.month:02d}/{task.download_id}.zip"
)
def _list_task_bundles(session: Session, task: WorkflowRunArchiveDownloadTask) -> list[WorkflowRunArchiveBundle]:
stmt = (
select(WorkflowRunArchiveBundle)
.where(
WorkflowRunArchiveBundle.tenant_id == task.tenant_id,
WorkflowRunArchiveBundle.year == task.year,
WorkflowRunArchiveBundle.month == task.month,
)
.order_by(WorkflowRunArchiveBundle.shard, WorkflowRunArchiveBundle.bundle_id)
)
indexed_bundles = list(session.scalars(stmt))
if task.bundle_refs:
requested_refs = [(ref.shard, ref.bundle_id) for ref in task.bundle_refs]
else:
requested_bundle_ids = set(task.bundle_ids)
requested_refs = [
(bundle.shard, bundle.bundle_id) for bundle in indexed_bundles if bundle.bundle_id in requested_bundle_ids
]
bundle_by_ref = {(bundle.shard, bundle.bundle_id): bundle for bundle in indexed_bundles}
missing_refs = [ref for ref in requested_refs if ref not in bundle_by_ref]
if missing_refs:
raise ValueError(f"archive bundle index is missing requested bundles: {missing_refs}")
bundles = [bundle_by_ref[ref] for ref in requested_refs]
if len(bundles) != task.bundle_count:
raise ValueError(f"archive bundle count changed: expected={task.bundle_count}, actual={len(bundles)}")
download_id = build_archive_download_id(
tenant_id=task.tenant_id,
year=task.year,
month=task.month,
bundle_refs=requested_refs,
)
if download_id != task.download_id:
raise ValueError("archive download id no longer matches indexed bundle set")
return bundles
def _build_archive_bundle_object_prefix(
task: WorkflowRunArchiveDownloadTask,
bundle: WorkflowRunArchiveBundle,
) -> str:
return (
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix={task.tenant_id[0].lower()}/tenant_id={task.tenant_id}/"
f"year={task.year:04d}/month={task.month:02d}/shard={bundle.shard}/bundle={bundle.bundle_id}"
)
def _load_and_validate_manifest(
storage: ArchiveStorage,
task: WorkflowRunArchiveDownloadTask,
bundle: WorkflowRunArchiveBundle,
object_prefix: str,
) -> tuple[bytes, ArchiveBundleManifest]:
manifest_key = f"{object_prefix}/{ARCHIVE_BUNDLE_MANIFEST_NAME}"
manifest_data = storage.get_object(manifest_key)
manifest = decode_archive_bundle_manifest(manifest_data)
_validate_manifest(task=task, bundle=bundle, manifest=manifest, object_prefix=object_prefix)
return manifest_data, manifest
def _validate_manifest(
*,
task: WorkflowRunArchiveDownloadTask,
bundle: WorkflowRunArchiveBundle,
manifest: ArchiveBundleManifest,
object_prefix: str,
) -> None:
if manifest["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
raise ValueError(f"unsupported archive bundle schema version: {manifest['schema_version']}")
if manifest["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
raise ValueError(f"unsupported archive bundle format: {manifest['archive_format']}")
if manifest["tenant_id"] != task.tenant_id:
raise ValueError(f"manifest tenant_id mismatch: expected={task.tenant_id}, actual={manifest['tenant_id']}")
if manifest["year"] != task.year:
raise ValueError(f"manifest year mismatch: expected={task.year}, actual={manifest['year']}")
if manifest["month"] != task.month:
raise ValueError(f"manifest month mismatch: expected={task.month}, actual={manifest['month']}")
if manifest["shard"] != bundle.shard:
raise ValueError(f"manifest shard mismatch: expected={bundle.shard}, actual={manifest['shard']}")
if manifest["bundle_id"] != bundle.bundle_id:
raise ValueError(f"manifest bundle_id mismatch: expected={bundle.bundle_id}, actual={manifest['bundle_id']}")
if manifest["object_prefix"] != object_prefix:
raise ValueError(
f"manifest object_prefix mismatch: expected={object_prefix}, actual={manifest['object_prefix']}"
)
if not manifest["tables"]:
raise ValueError("manifest tables must not be empty")
for table_name, raw_entry in manifest["tables"].items():
entry = cast(ArchiveBundleTableManifestEntry, raw_entry)
expected_object_key = f"{object_prefix}/{table_name}.parquet"
if entry["object_key"] != expected_object_key:
raise ValueError(
f"manifest object_key mismatch for {table_name}: "
f"expected={expected_object_key}, actual={entry['object_key']}"
)
def _validate_table_payload(
*,
object_key: str,
entry: ArchiveBundleTableManifestEntry,
payload: bytes,
) -> None:
if len(payload) != entry["size_bytes"]:
raise ValueError(f"archive object size mismatch for {object_key}")
checksum = hashlib.md5(payload).hexdigest()
if checksum != entry["checksum"]:
raise ValueError(f"archive object checksum mismatch for {object_key}")
def _parquet_payload_to_csv(payload: bytes, *, include_header: bool) -> bytes:
table = pq.read_table(io.BytesIO(payload))
if table.num_columns == 0:
return b""
for index, field in enumerate(table.schema):
if pa.types.is_string(field.type) or pa.types.is_large_string(field.type):
sanitized_column = pc.replace_substring_regex( # pyrefly: ignore[missing-attribute]
table.column(index),
pattern=_CSV_FORMULA_PREFIX_PATTERN,
replacement=r"'\0",
max_replacements=1,
)
table = table.set_column(index, field, sanitized_column)
buffer = io.BytesIO()
pa_csv.write_csv(
table,
buffer,
write_options=pa_csv.WriteOptions(include_header=include_header),
)
return buffer.getvalue()
@@ -0,0 +1,177 @@
"""Redis-backed temporary state for workflow-run archive downloads."""
import datetime
import hashlib
import json
import logging
from collections.abc import Sequence
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from extensions.ext_redis import RedisClientWrapper, redis_client
logger = logging.getLogger(__name__)
ARCHIVE_DOWNLOAD_FORMAT_VERSION = "v1"
DEFAULT_ARCHIVE_DOWNLOAD_TASK_TTL_SECONDS = 24 * 60 * 60
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS = 30
_CACHE_KEY_PREFIX = "workflow_run_archive_download"
class WorkflowRunArchiveDownloadStatus(StrEnum):
"""Lifecycle state for an asynchronous archive download request."""
PENDING = "pending"
PROCESSING = "processing"
READY = "ready"
FAILED = "failed"
class WorkflowRunArchiveBundleRef(BaseModel):
"""Immutable object-store identity for one bundle included in a download task."""
model_config = ConfigDict(extra="forbid")
shard: str
bundle_id: str
class WorkflowRunArchiveDownloadTask(BaseModel):
"""Temporary Redis payload for a monthly archive download request."""
model_config = ConfigDict(extra="forbid")
download_id: str
tenant_id: str
requested_by: str
year: int = Field(ge=1)
month: int = Field(ge=1, le=12)
bundle_ids: list[str]
bundle_refs: list[WorkflowRunArchiveBundleRef] = Field(default_factory=list)
bundle_count: int = Field(ge=0)
archive_bytes: int = Field(ge=0)
status: WorkflowRunArchiveDownloadStatus
file_name: str | None = None
storage_key: str | None = None
file_size_bytes: int | None = Field(default=None, ge=0)
celery_task_id: str | None = None
error: str | None = None
created_at: datetime.datetime
updated_at: datetime.datetime
expires_at: datetime.datetime
started_at: datetime.datetime | None = None
finished_at: datetime.datetime | None = None
class WorkflowRunArchiveDownloadTaskCache:
"""Store ephemeral archive download task state in Redis with a TTL."""
_redis: RedisClientWrapper
def __init__(self, redis: RedisClientWrapper = redis_client) -> None:
self._redis = redis
def get(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
raw = self._redis.get(self._cache_key(tenant_id=tenant_id, download_id=download_id))
if raw is None:
return None
data = raw.decode("utf-8") if isinstance(raw, bytes | bytearray) else raw
try:
return WorkflowRunArchiveDownloadTask.model_validate_json(data)
except ValueError:
logger.warning("Malformed workflow run archive download task cache entry: %s", download_id)
return None
def save(self, task: WorkflowRunArchiveDownloadTask) -> None:
ttl_seconds = self._ttl_seconds(task.expires_at)
self._redis.setex(
self._cache_key(tenant_id=task.tenant_id, download_id=task.download_id),
ttl_seconds,
task.model_dump_json(),
)
def lock(self, *, tenant_id: str, download_id: str) -> Any:
return self._redis.lock(
f"{self._cache_key(tenant_id=tenant_id, download_id=download_id)}:lock",
timeout=ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
blocking_timeout=ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
)
def delete(self, *, tenant_id: str, download_id: str) -> None:
self._redis.delete(self._cache_key(tenant_id=tenant_id, download_id=download_id))
@staticmethod
def _cache_key(*, tenant_id: str, download_id: str) -> str:
return f"{_CACHE_KEY_PREFIX}:{tenant_id}:{download_id}"
@staticmethod
def _ttl_seconds(expires_at: datetime.datetime) -> int:
expires_at_utc = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=datetime.UTC)
remaining = expires_at_utc - datetime.datetime.now(datetime.UTC)
return max(int(remaining.total_seconds()), 1)
def build_pending_archive_download_task(
*,
tenant_id: str,
requested_by: str,
year: int,
month: int,
bundle_ids: Sequence[str],
bundle_refs: Sequence[tuple[str, str]] = (),
archive_bytes: int,
download_id: str,
ttl_seconds: int = DEFAULT_ARCHIVE_DOWNLOAD_TASK_TTL_SECONDS,
now: datetime.datetime | None = None,
) -> WorkflowRunArchiveDownloadTask:
"""Create the Redis payload stored when the console starts an archive download."""
created_at = now or datetime.datetime.now(datetime.UTC)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=datetime.UTC)
normalized_bundle_ids = list(bundle_ids)
normalized_bundle_refs = [
WorkflowRunArchiveBundleRef(shard=shard, bundle_id=bundle_id) for shard, bundle_id in bundle_refs
]
return WorkflowRunArchiveDownloadTask(
download_id=download_id,
tenant_id=tenant_id,
requested_by=requested_by,
year=year,
month=month,
bundle_ids=normalized_bundle_ids,
bundle_refs=normalized_bundle_refs,
bundle_count=len(normalized_bundle_ids),
archive_bytes=archive_bytes,
status=WorkflowRunArchiveDownloadStatus.PENDING,
created_at=created_at,
updated_at=created_at,
expires_at=created_at + datetime.timedelta(seconds=ttl_seconds),
)
def build_archive_download_id(
*,
tenant_id: str,
year: int,
month: int,
bundle_refs: Sequence[tuple[str, str]],
download_format_version: str = ARCHIVE_DOWNLOAD_FORMAT_VERSION,
) -> str:
"""Build a stable id for the exact archive download content."""
if not bundle_refs:
raise ValueError("bundle_refs must not be empty")
normalized_refs = sorted(f"{shard}:{bundle_id}" for shard, bundle_id in bundle_refs)
payload = json.dumps(
{
"tenant_id": tenant_id,
"year": year,
"month": month,
"bundle_refs": normalized_refs,
"download_format_version": download_format_version,
},
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
@@ -0,0 +1,298 @@
"""
Console-facing workflow-run archive queries.
The object store remains the recoverable archive source of truth. This module only reads the DB bundle index and writes
temporary Redis download-task state, so console requests never list R2 online.
"""
import datetime
import logging
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from models.workflow import WorkflowRunArchiveBundle
from services.retention.workflow_run.archive_download_task_cache import (
WorkflowRunArchiveDownloadStatus,
WorkflowRunArchiveDownloadTask,
WorkflowRunArchiveDownloadTaskCache,
build_archive_download_id,
build_pending_archive_download_task,
)
logger = logging.getLogger(__name__)
ArchiveDownloadTaskDispatcher = Callable[
[WorkflowRunArchiveDownloadTask, WorkflowRunArchiveDownloadTaskCache],
WorkflowRunArchiveDownloadTask,
]
@dataclass(frozen=True)
class WorkflowRunArchiveMonth:
"""Aggregated archive metadata for one tenant/month."""
year: int
month: int
bundle_count: int
workflow_run_count: int
row_count: int
archive_bytes: int
latest_archived_at: datetime.datetime
download_task: WorkflowRunArchiveDownloadTask | None
@dataclass(frozen=True)
class WorkflowRunArchiveSummary:
"""Top-level archive totals shown on the console page."""
archived_month_count: int
workflow_run_count: int
archive_bytes: int
latest_archived_at: datetime.datetime | None
@dataclass(frozen=True)
class WorkflowRunArchiveList:
"""Console response model before controller serialization."""
summary: WorkflowRunArchiveSummary
months: list[WorkflowRunArchiveMonth]
class WorkflowRunArchiveNotFoundError(Exception):
"""Raised when no archive bundles exist for a requested tenant/month."""
class WorkflowRunArchiveDownloadTaskNotFoundError(Exception):
"""Raised when the temporary Redis task has expired or never existed."""
class WorkflowRunArchiveDownloadNotReadyError(Exception):
"""Raised when a cached download task has not produced a file yet."""
def list_workflow_run_archives(
session: Session,
tenant_id: str,
*,
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
) -> WorkflowRunArchiveList:
"""Return monthly archive metadata for one tenant from the DB bundle index."""
stmt = (
select(WorkflowRunArchiveBundle)
.where(WorkflowRunArchiveBundle.tenant_id == tenant_id)
.order_by(
WorkflowRunArchiveBundle.year.desc(),
WorkflowRunArchiveBundle.month.desc(),
WorkflowRunArchiveBundle.shard,
WorkflowRunArchiveBundle.bundle_id,
)
)
month_bundles: dict[tuple[int, int], list[WorkflowRunArchiveBundle]] = {}
for bundle in session.scalars(stmt):
month_bundles.setdefault((bundle.year, bundle.month), []).append(bundle)
task_cache = cache or WorkflowRunArchiveDownloadTaskCache()
months: list[WorkflowRunArchiveMonth] = []
for (year, month), bundles in month_bundles.items():
bundle_refs = [(bundle.shard, bundle.bundle_id) for bundle in bundles]
months.append(
WorkflowRunArchiveMonth(
year=year,
month=month,
bundle_count=len(bundles),
workflow_run_count=sum(bundle.workflow_run_count for bundle in bundles),
row_count=sum(bundle.row_count for bundle in bundles),
archive_bytes=sum(bundle.archive_bytes for bundle in bundles),
latest_archived_at=max(bundle.archived_at for bundle in bundles),
download_task=_get_cached_month_download_task(
task_cache,
tenant_id=tenant_id,
year=year,
month=month,
bundle_refs=bundle_refs,
),
)
)
latest_archived_at = max((month.latest_archived_at for month in months), default=None)
return WorkflowRunArchiveList(
summary=WorkflowRunArchiveSummary(
archived_month_count=len(months),
workflow_run_count=sum(month.workflow_run_count for month in months),
archive_bytes=sum(month.archive_bytes for month in months),
latest_archived_at=latest_archived_at,
),
months=months,
)
def _get_cached_month_download_task(
cache: WorkflowRunArchiveDownloadTaskCache,
*,
tenant_id: str,
year: int,
month: int,
bundle_refs: list[tuple[str, str]],
) -> WorkflowRunArchiveDownloadTask | None:
if not bundle_refs:
return None
download_id = build_archive_download_id(
tenant_id=tenant_id,
year=year,
month=month,
bundle_refs=bundle_refs,
)
try:
return cache.get(tenant_id=tenant_id, download_id=download_id)
except Exception:
logger.warning("Failed to read cached workflow run archive download task: %s", download_id, exc_info=True)
return None
def create_workflow_run_archive_download_task(
session: Session,
*,
tenant_id: str,
requested_by: str,
year: int,
month: int,
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
dispatcher: ArchiveDownloadTaskDispatcher | None = None,
) -> WorkflowRunArchiveDownloadTask:
"""
Create or return the idempotent Redis task for downloading one tenant/month archive.
The task identity is based on the exact ordered bundle set currently indexed for the month. If the month receives a
new bundle later, the next request gets a different download id and prepares a fresh file.
"""
bundles = _list_archive_bundles(session, tenant_id=tenant_id, year=year, month=month)
if not bundles:
raise WorkflowRunArchiveNotFoundError(f"Workflow run archive not found: {year:04d}-{month:02d}")
bundle_refs = [(bundle.shard, bundle.bundle_id) for bundle in bundles]
download_id = build_archive_download_id(
tenant_id=tenant_id,
year=year,
month=month,
bundle_refs=bundle_refs,
)
task = build_pending_archive_download_task(
tenant_id=tenant_id,
requested_by=requested_by,
year=year,
month=month,
bundle_ids=[bundle.bundle_id for bundle in bundles],
bundle_refs=bundle_refs,
archive_bytes=sum(bundle.archive_bytes for bundle in bundles),
download_id=download_id,
)
task_cache = cache or WorkflowRunArchiveDownloadTaskCache()
dispatch = dispatcher or _dispatch_workflow_run_archive_download_task
with task_cache.lock(tenant_id=tenant_id, download_id=download_id):
existing = task_cache.get(tenant_id=tenant_id, download_id=download_id)
if existing is None or existing.status == WorkflowRunArchiveDownloadStatus.FAILED:
task_to_queue = task
elif existing.status == WorkflowRunArchiveDownloadStatus.PENDING and not existing.celery_task_id:
task_to_queue = existing
else:
return existing
queued_task = task_to_queue.model_copy(
update={"celery_task_id": uuid.uuid4().hex, "updated_at": datetime.datetime.now(datetime.UTC)}
)
task_cache.save(queued_task)
return dispatch(queued_task, task_cache)
def get_workflow_run_archive_download_task(
*,
tenant_id: str,
download_id: str,
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
) -> WorkflowRunArchiveDownloadTask:
"""Return a cached archive download task or raise when the TTL has expired."""
task_cache = cache or WorkflowRunArchiveDownloadTaskCache()
task = task_cache.get(tenant_id=tenant_id, download_id=download_id)
if task is None:
raise WorkflowRunArchiveDownloadTaskNotFoundError(f"Workflow run archive download not found: {download_id}")
return task
def get_ready_workflow_run_archive_download_task(
*,
tenant_id: str,
download_id: str,
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
) -> WorkflowRunArchiveDownloadTask:
"""Return a ready cached archive download task or raise when the file is not available."""
task = get_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id, cache=cache)
if task.status != WorkflowRunArchiveDownloadStatus.READY or not task.storage_key or not task.file_name:
raise WorkflowRunArchiveDownloadNotReadyError(f"Workflow run archive download is not ready: {download_id}")
return task
def _list_archive_bundles(
session: Session,
*,
tenant_id: str,
year: int,
month: int,
) -> list[WorkflowRunArchiveBundle]:
stmt = (
select(WorkflowRunArchiveBundle)
.where(
WorkflowRunArchiveBundle.tenant_id == tenant_id,
WorkflowRunArchiveBundle.year == year,
WorkflowRunArchiveBundle.month == month,
)
.order_by(WorkflowRunArchiveBundle.shard, WorkflowRunArchiveBundle.bundle_id)
)
return list(session.scalars(stmt))
def _dispatch_workflow_run_archive_download_task(
task: WorkflowRunArchiveDownloadTask,
cache: WorkflowRunArchiveDownloadTaskCache,
) -> WorkflowRunArchiveDownloadTask:
"""
Enqueue background ZIP preparation after the caller atomically claimed and cached the Celery id.
"""
from tasks.workflow_run_archive_download_tasks import prepare_workflow_run_archive_download_task
celery_task_id = task.celery_task_id
if celery_task_id is None:
raise ValueError("celery_task_id is required before dispatch")
try:
prepare_workflow_run_archive_download_task.apply_async(
args=(task.tenant_id, task.download_id),
task_id=celery_task_id,
)
except Exception:
failure_time = datetime.datetime.now(datetime.UTC)
failed_task = task.model_copy(
update={
"status": WorkflowRunArchiveDownloadStatus.FAILED,
"error": "Failed to enqueue archive download task.",
"updated_at": failure_time,
"finished_at": failure_time,
}
)
with cache.lock(tenant_id=task.tenant_id, download_id=task.download_id):
current = cache.get(tenant_id=task.tenant_id, download_id=task.download_id)
if (
current is not None
and current.status == WorkflowRunArchiveDownloadStatus.PENDING
and current.celery_task_id == celery_task_id
):
cache.save(failed_task)
current = failed_task
logger.exception("Failed to enqueue workflow run archive download task %s", task.download_id)
return current or failed_task
return task
@@ -5,8 +5,8 @@ This service archives workflow run logs for paid plan users older than the confi
90 days) to S3-compatible storage.
Archive V2 writes bundle-level Parquet objects. A bundle contains many workflow runs and their related table rows.
Bundle metadata lives in the object-store manifest instead of a database table, so archive/delete/restore does not move
the large-table retention problem into another OLTP table.
Bundle metadata lives in the object-store manifest as the recoverable source of truth. Completed bundles are also
mirrored into a small database index so console listing and download jobs do not list object storage online.
Archive campaigns should use fixed absolute UTC windows for every tenant-prefix/shard execution. Relative windows are
evaluated at process start and are not safe for multi-day rollout because each command would scan a different window.
@@ -64,6 +64,12 @@ from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowN
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.billing_service import BillingService
from services.retention.workflow_run.archive_bundle_index import (
ArchiveBundleManifest,
ArchiveBundleTableManifestEntry,
decode_archive_bundle_manifest,
upsert_archive_bundle_index_from_manifest,
)
from services.retention.workflow_run.constants import (
ARCHIVE_BUNDLE_FORMAT,
ARCHIVE_BUNDLE_INDEX_NAME,
@@ -634,6 +640,7 @@ class WorkflowRunArchiver:
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
if storage.object_exists(self._get_manifest_object_key(identity)):
self._write_bundle_index(storage, identity)
self._sync_existing_bundle_index(session, storage, identity)
result.success = True
result.skipped = True
result.error = "bundle already archived"
@@ -657,6 +664,7 @@ class WorkflowRunArchiver:
result.run_count = len(runs)
if storage.object_exists(self._get_manifest_object_key(identity)):
self._write_bundle_index(storage, identity)
self._sync_existing_bundle_index(session, storage, identity)
result.success = True
result.skipped = True
result.error = "filtered bundle already archived"
@@ -688,6 +696,8 @@ class WorkflowRunArchiver:
storage.put_object(self._get_table_object_key(identity, table_name), payload)
storage.put_object(self._get_manifest_object_key(identity), manifest_data)
self._merge_bundle_manifest_into_index(storage, identity, [run.id for run in runs])
manifest = decode_archive_bundle_manifest(manifest_data)
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
session.commit()
logger.info(
@@ -714,6 +724,23 @@ class WorkflowRunArchiver:
result.elapsed_time = time.time() - start_time
return result
def _sync_existing_bundle_index(
self,
session: Session,
storage: ArchiveStorage,
identity: ArchiveBundleIdentity,
) -> None:
"""Best-effort DB index sync for a bundle whose manifest already exists in archive storage."""
manifest_key = self._get_manifest_object_key(identity)
try:
manifest_data = storage.get_object(manifest_key)
manifest = decode_archive_bundle_manifest(manifest_data)
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
session.commit()
except Exception:
session.rollback()
logger.warning("Failed to sync workflow archive bundle index for %s", manifest_key, exc_info=True)
def _lock_runs_for_archive(
self,
session: Session,
@@ -841,9 +868,9 @@ class WorkflowRunArchiver:
identity: ArchiveBundleIdentity,
runs: Sequence[WorkflowRun],
table_stats: list[TableStats],
) -> ArchiveManifestDict:
) -> ArchiveBundleManifest:
"""Generate a manifest for the archived workflow run bundle."""
tables: dict[str, TableStatsManifestEntry] = {
tables: dict[str, ArchiveBundleTableManifestEntry] = {
stat.table_name: {
"row_count": stat.row_count,
"checksum": stat.checksum,
@@ -1,9 +1,10 @@
"""
Maintain V2 workflow-run archive bundles.
Archive V2 keeps bundle metadata in object-store manifests, not in a database table. This module discovers bundles by
listing `manifest.json` objects, uses object-store marker files for delete/restore state, and only touches the database
for source-table validation, deletion, and restoration.
Archive V2 keeps object-store manifests as the recoverable bundle source of truth. This maintenance module still
discovers delete/restore targets by listing `manifest.json` objects and uses object-store marker files for
delete/restore state. The separate database bundle index is intended for console listing and download jobs, not as the
source of truth for destructive maintenance.
Each bundle is processed in its own database transaction. A failed bundle leaves source rows unchanged unless the
transaction has already committed; marker handling makes the next run able to reconcile the common committed-but-marker
@@ -0,0 +1,18 @@
"""Celery tasks for preparing workflow-run archive downloads."""
import logging
from celery import shared_task
from services.retention.workflow_run.archive_download_preparation import WorkflowRunArchiveDownloadPreparer
logger = logging.getLogger(__name__)
WORKFLOW_RUN_ARCHIVE_DOWNLOAD_QUEUE = "workflow_archive"
@shared_task(queue=WORKFLOW_RUN_ARCHIVE_DOWNLOAD_QUEUE)
def prepare_workflow_run_archive_download_task(tenant_id: str, download_id: str) -> None:
"""Prepare a cached workflow-run archive download in the background."""
logger.info("Preparing workflow run archive download: tenant=%s download_id=%s", tenant_id, download_id)
WorkflowRunArchiveDownloadPreparer().prepare(tenant_id=tenant_id, download_id=download_id)
@@ -8,6 +8,7 @@ import pyarrow.parquet as pq
import pytest
from sqlalchemy.exc import OperationalError
from models.workflow import WorkflowRunArchiveBundle
from services.retention.workflow_run.archive_paid_plan_workflow_run import (
ArchiveResult,
ArchiveSummary,
@@ -518,6 +519,37 @@ class TestArchiveRunIdempotency:
assert result.skipped is True
assert result.error == "bundle already archived"
def test_successful_bundle_persists_archive_index(self):
archiver = WorkflowRunArchiver(days=90)
run = MagicMock()
run.id = str(uuid.uuid4())
run.tenant_id = str(uuid.uuid4())
run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
session = MagicMock()
session.scalar.return_value = None
storage = MagicMock()
storage.object_exists.return_value = False
table_data = {
"workflow_runs": [{"id": run.id, "tenant_id": run.tenant_id}],
"workflow_node_executions": [{"id": str(uuid.uuid4()), "workflow_run_id": run.id}],
}
with (
patch.object(archiver, "_lock_runs_for_archive", return_value=[run]),
patch.object(archiver, "_extract_bundle_data", return_value=table_data),
):
result = archiver._archive_bundle(session, storage, [run])
archived_bundle = session.add.call_args.args[0]
assert result.success is True
assert isinstance(archived_bundle, WorkflowRunArchiveBundle)
assert archived_bundle.tenant_id == run.tenant_id
assert archived_bundle.year == 2025
assert archived_bundle.month == 3
assert archived_bundle.workflow_run_count == 1
assert archived_bundle.row_count == 2
session.commit.assert_called_once()
def test_index_skips_all_already_archived_runs(self):
archiver = WorkflowRunArchiver(days=90)
run = MagicMock()
@@ -0,0 +1,30 @@
import pytest
from controllers.console.workflow_run_archive import (
WorkflowRunArchiveDownloadApi,
WorkflowRunArchiveDownloadFileApi,
WorkflowRunArchiveDownloadsApi,
WorkflowRunArchivesApi,
)
@pytest.mark.parametrize(
"method",
[
WorkflowRunArchivesApi.get,
WorkflowRunArchiveDownloadsApi.post,
WorkflowRunArchiveDownloadApi.get,
WorkflowRunArchiveDownloadFileApi.get,
],
)
def test_workflow_run_archive_endpoints_require_cloud_paid_plan(method) -> None:
decorator_names = set()
while hasattr(method, "__wrapped__"):
decorator_names.add(method.__code__.co_qualname.partition(".<locals>")[0])
method = method.__wrapped__
assert {
"only_edition_cloud",
"cloud_edition_billing_enabled",
"cloud_edition_billing_paid_plan_required",
} <= decorator_names
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
_is_setup_completed,
account_initialization_required,
cloud_edition_billing_enabled,
cloud_edition_billing_paid_plan_required,
cloud_edition_billing_rate_limit_check,
cloud_edition_billing_resource_check,
cloud_utm_record,
@@ -488,6 +489,53 @@ class TestBillingEnabled:
get_features.assert_not_called()
class TestBillingPaidPlanRequired:
@pytest.mark.parametrize("plan", ["professional", "team"])
def test_should_allow_paid_plan(self, plan: str):
@cloud_edition_billing_paid_plan_required
def paid_view():
return "paid_success"
billing_info = {"enabled": True, "subscription": {"plan": plan}}
with (
patch(
"controllers.console.wraps.current_account_with_tenant",
return_value=(MockUser("test_user"), "tenant123"),
),
patch("controllers.console.wraps.BillingService.get_info", return_value=billing_info) as get_info,
):
result = paid_view()
assert result == "paid_success"
get_info.assert_called_once_with("tenant123", exclude_vector_space=True)
@pytest.mark.parametrize(
("enabled", "plan"),
[(False, "professional"), (True, "sandbox"), (True, "unknown")],
)
def test_should_reject_non_paid_plan(self, enabled: bool, plan: str):
app = create_app_with_login()
@cloud_edition_billing_paid_plan_required
def paid_view():
return "paid_success"
billing_info = {"enabled": enabled, "subscription": {"plan": plan}}
with app.test_request_context():
with (
patch(
"controllers.console.wraps.current_account_with_tenant",
return_value=(MockUser("test_user"), "tenant123"),
),
patch("controllers.console.wraps.BillingService.get_info", return_value=billing_info),
pytest.raises(HTTPException) as exc_info,
):
paid_view()
assert exc_info.value.code == 403
assert "requires a paid plan" in str(exc_info.value.description)
class TestBillingResourceLimits:
"""Test billing resource limit decorators"""
@@ -247,6 +247,32 @@ def test_generate_presigned_url(monkeypatch: pytest.MonkeyPatch):
assert url == "http://signed-url"
def test_generate_presigned_url_with_download_headers(monkeypatch: pytest.MonkeyPatch):
_configure_storage(monkeypatch)
client, _ = _mock_client(monkeypatch)
client.generate_presigned_url.return_value = "http://signed-url"
storage = ArchiveStorage(bucket=BUCKET_NAME)
url = storage.generate_presigned_url(
"key",
expires_in=123,
filename="workflow-run-logs-2025-03.zip",
content_type="application/zip",
)
client.generate_presigned_url.assert_called_once_with(
ClientMethod="get_object",
Params={
"Bucket": "archive-bucket",
"Key": "key",
"ResponseContentDisposition": "attachment; filename*=UTF-8''workflow-run-logs-2025-03.zip",
"ResponseContentType": "application/zip",
},
ExpiresIn=123,
)
assert url == "http://signed-url"
def test_generate_presigned_url_error(monkeypatch: pytest.MonkeyPatch):
_configure_storage(monkeypatch)
client, _ = _mock_client(monkeypatch)
+30 -1
View File
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock
import pytest
from flask import Request
from werkzeug.exceptions import Unauthorized
from werkzeug.wrappers import Response
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_WEBAPP_ACCESS_TOKEN
@@ -11,10 +12,17 @@ from libs.token import extract_access_token, extract_webapp_access_token, set_cs
class MockRequest:
def __init__(self, headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]):
def __init__(
self,
headers: dict[str, str],
cookies: dict[str, str],
args: dict[str, str],
path: str = "/console/api/test",
):
self.headers: dict[str, str] = headers
self.cookies: dict[str, str] = cookies
self.args: dict[str, str] = args
self.path = path
def test_extract_access_token():
@@ -63,3 +71,24 @@ def test_set_csrf_cookie_includes_domain_when_configured(monkeypatch: pytest.Mon
assert any("csrf_token=abc123" in c for c in cookies)
assert any("Domain=example.com" in c for c in cookies)
assert all("__Host-" not in c for c in cookies)
def test_workflow_run_archive_download_file_bypasses_csrf():
request = cast(
Request,
MockRequest(
headers={},
cookies={},
args={},
path="/console/api/workflow-run-archives/downloads/5923ce20291444af45f0580fb49f1cc9/file",
),
)
token.check_csrf_token(request, "account-1")
def test_non_whitelisted_path_requires_csrf():
request = cast(Request, MockRequest(headers={}, cookies={}, args={}, path="/console/api/test"))
with pytest.raises(Unauthorized):
token.check_csrf_token(request, "account-1")
@@ -0,0 +1,172 @@
import datetime
import json
from typing import cast
from unittest.mock import MagicMock
from services.retention.workflow_run.archive_bundle_index import (
ARCHIVE_BUNDLE_ROOT_PREFIX,
ArchiveBundleManifest,
WorkflowRunArchiveBundleIndexBackfill,
calculate_archive_bundle_index_values,
decode_archive_bundle_manifest,
upsert_archive_bundle_index_from_manifest,
)
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
TENANT_ID = "1251fe32-c0c7-4fe2-a7bd-a8105267faf5"
BUNDLE_ID = "bundle-a"
OBJECT_PREFIX = (
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/"
f"year=2025/month=03/shard=00-of-01/bundle={BUNDLE_ID}"
)
MANIFEST_KEY = f"{OBJECT_PREFIX}/manifest.json"
class FakeArchiveStorage:
listed_prefixes: list[str]
objects: dict[str, bytes]
def __init__(self, objects: dict[str, bytes]) -> None:
self.objects = objects
self.listed_prefixes = []
def list_objects(self, prefix: str) -> list[str]:
self.listed_prefixes.append(prefix)
return sorted(key for key in self.objects if key.startswith(prefix))
def get_object(self, key: str) -> bytes:
return self.objects[key]
def _manifest(*, object_prefix: str = OBJECT_PREFIX, month: int = 3) -> ArchiveBundleManifest:
return ArchiveBundleManifest(
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
archive_format=ARCHIVE_BUNDLE_FORMAT,
tenant_id=TENANT_ID,
tenant_prefix="1",
year=2025,
month=month,
shard="00-of-01",
bundle_id=BUNDLE_ID,
object_prefix=object_prefix,
workflow_run_count=2,
workflow_node_execution_count=3,
min_created_at="2025-03-01T00:00:00+00:00",
max_created_at="2025-03-02T00:00:00+00:00",
min_run_id="run-a",
max_run_id="run-b",
archived_at="2026-06-25T08:00:00+00:00",
tables={
"workflow_runs": {
"row_count": 2,
"checksum": "checksum-a",
"size_bytes": 100,
"object_key": f"{object_prefix}/workflow_runs.parquet",
},
"workflow_node_executions": {
"row_count": 3,
"checksum": "checksum-b",
"size_bytes": 200,
"object_key": f"{object_prefix}/workflow_node_executions.parquet",
},
},
run_ids=["run-a", "run-b"],
)
def _manifest_bytes(manifest: ArchiveBundleManifest | None = None) -> bytes:
return json.dumps(manifest or _manifest()).encode("utf-8")
def test_decode_and_calculate_archive_bundle_index_values() -> None:
data = _manifest_bytes()
manifest = decode_archive_bundle_manifest(data)
values = calculate_archive_bundle_index_values(manifest, len(data))
assert manifest["tenant_id"] == TENANT_ID
assert values.row_count == 5
assert values.archive_bytes == len(data) + 300
assert values.archived_at == datetime.datetime(2026, 6, 25, 8, 0)
def test_upsert_archive_bundle_index_inserts_new_bundle() -> None:
session = MagicMock()
session.scalar.return_value = None
data = _manifest_bytes()
bundle = upsert_archive_bundle_index_from_manifest(session, decode_archive_bundle_manifest(data), len(data))
assert bundle.tenant_id == TENANT_ID
assert bundle.year == 2025
assert bundle.month == 3
assert bundle.workflow_run_count == 2
assert bundle.row_count == 5
assert bundle.archive_bytes == len(data) + 300
session.add.assert_called_once_with(bundle)
def test_upsert_archive_bundle_index_updates_existing_bundle() -> None:
existing = MagicMock()
session = MagicMock()
session.scalar.return_value = existing
data = _manifest_bytes()
bundle = upsert_archive_bundle_index_from_manifest(session, decode_archive_bundle_manifest(data), len(data))
assert bundle is existing
assert existing.workflow_run_count == 2
assert existing.row_count == 5
assert existing.archive_bytes == len(data) + 300
assert existing.archived_at == datetime.datetime(2026, 6, 25, 8, 0)
session.add.assert_not_called()
def test_backfill_lists_tenant_month_prefix_and_upserts_bundle_index() -> None:
storage = FakeArchiveStorage({MANIFEST_KEY: _manifest_bytes()})
session = MagicMock()
session.scalar.return_value = None
session_factory = MagicMock()
session_factory.return_value.__enter__.return_value = session
backfill = WorkflowRunArchiveBundleIndexBackfill(
storage=cast(MagicMock, storage),
session_factory=cast(MagicMock, session_factory),
)
summary = backfill.run(tenant_ids=[TENANT_ID], year=2025, month=3)
assert storage.listed_prefixes == [
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/year=2025/month=03/"
]
assert summary.manifests_found == 1
assert summary.bundles_processed == 1
assert summary.bundles_upserted == 1
assert summary.bundles_failed == 0
session.add.assert_called_once()
session.commit.assert_called_once()
def test_backfill_dry_run_filters_by_year_month_without_database_write() -> None:
other_month_prefix = OBJECT_PREFIX.replace("month=03", "month=04")
storage = FakeArchiveStorage(
{
MANIFEST_KEY: _manifest_bytes(),
f"{other_month_prefix}/manifest.json": _manifest_bytes(
_manifest(object_prefix=other_month_prefix, month=4)
),
}
)
session_factory = MagicMock()
backfill = WorkflowRunArchiveBundleIndexBackfill(
storage=cast(MagicMock, storage),
session_factory=cast(MagicMock, session_factory),
)
summary = backfill.run(tenant_prefixes=["1"], year=2025, month=3, dry_run=True)
assert storage.listed_prefixes == [f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/"]
assert summary.manifests_found == 1
assert summary.bundles_processed == 1
assert summary.bundles_upserted == 0
assert summary.archive_bytes > 0
session_factory.assert_not_called()
@@ -0,0 +1,292 @@
import datetime
import hashlib
import io
import json
import zipfile
from contextlib import nullcontext
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
import pyarrow as pa
import pyarrow.parquet as pq
from sqlalchemy.orm import Session, sessionmaker
from libs.archive_storage import ArchiveStorage
from models.workflow import WorkflowRunArchiveBundle
from services.retention.workflow_run.archive_bundle_index import ARCHIVE_BUNDLE_ROOT_PREFIX, ArchiveBundleManifest
from services.retention.workflow_run.archive_download_preparation import (
WorkflowRunArchiveDownloadPreparer,
build_archive_download_storage_key,
)
from services.retention.workflow_run.archive_download_task_cache import (
WorkflowRunArchiveDownloadStatus,
WorkflowRunArchiveDownloadTask,
WorkflowRunArchiveDownloadTaskCache,
build_archive_download_id,
build_pending_archive_download_task,
)
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
TENANT_ID = "1251fe32-c0c7-4fe2-a7bd-a8105267faf5"
BUNDLE_ID = "bundle-a"
SHARD = "00-of-01"
OBJECT_PREFIX = (
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/"
f"year=2025/month=03/shard={SHARD}/bundle={BUNDLE_ID}"
)
MANIFEST_KEY = f"{OBJECT_PREFIX}/manifest.json"
class FakeArchiveStorage:
objects: dict[str, bytes]
put_objects: dict[str, bytes]
def __init__(self, objects: dict[str, bytes]) -> None:
self.objects = dict(objects)
self.put_objects = {}
def get_object(self, key: str) -> bytes:
return self.objects[key]
def put_object(self, key: str, data: bytes) -> str:
self.put_objects[key] = data
return hashlib.md5(data).hexdigest()
class FakeTaskCache:
task: WorkflowRunArchiveDownloadTask | None
saved_tasks: list[WorkflowRunArchiveDownloadTask]
def __init__(self, task: WorkflowRunArchiveDownloadTask | None) -> None:
self.task = task
self.saved_tasks = []
def get(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
if self.task and self.task.tenant_id == tenant_id and self.task.download_id == download_id:
return self.task
return None
def lock(self, *, tenant_id: str, download_id: str):
return nullcontext()
def save(self, task: WorkflowRunArchiveDownloadTask) -> None:
self.task = task
self.saved_tasks.append(task)
def _object_prefix(bundle_id: str = BUNDLE_ID) -> str:
return (
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/"
f"year=2025/month=03/shard={SHARD}/bundle={bundle_id}"
)
def _bundle(bundle_id: str = BUNDLE_ID) -> WorkflowRunArchiveBundle:
return cast(WorkflowRunArchiveBundle, SimpleNamespace(shard=SHARD, bundle_id=bundle_id))
def _task(bundle_refs: list[tuple[str, str]] | None = None) -> WorkflowRunArchiveDownloadTask:
refs = bundle_refs or [(SHARD, BUNDLE_ID)]
return build_pending_archive_download_task(
tenant_id=TENANT_ID,
requested_by="account-1",
year=2025,
month=3,
bundle_ids=[bundle_id for _, bundle_id in refs],
bundle_refs=refs,
archive_bytes=1024,
download_id=build_archive_download_id(
tenant_id=TENANT_ID,
year=2025,
month=3,
bundle_refs=refs,
),
now=datetime.datetime(2026, 6, 25, 8, 0, tzinfo=datetime.UTC),
)
def _manifest_bytes(table_payloads: dict[str, bytes], *, bundle_id: str = BUNDLE_ID) -> bytes:
object_prefix = _object_prefix(bundle_id)
manifest = ArchiveBundleManifest(
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
archive_format=ARCHIVE_BUNDLE_FORMAT,
tenant_id=TENANT_ID,
tenant_prefix="1",
year=2025,
month=3,
shard=SHARD,
bundle_id=bundle_id,
object_prefix=object_prefix,
workflow_run_count=2,
workflow_node_execution_count=0,
min_created_at="2025-03-01T00:00:00+00:00",
max_created_at="2025-03-02T00:00:00+00:00",
min_run_id="run-a",
max_run_id="run-b",
archived_at="2026-06-25T08:00:00+00:00",
tables={
table_name: {
"row_count": 1,
"checksum": hashlib.md5(payload).hexdigest(),
"size_bytes": len(payload),
"object_key": f"{object_prefix}/{table_name}.parquet",
}
for table_name, payload in table_payloads.items()
},
run_ids=["run-a", "run-b"],
)
return json.dumps(manifest).encode("utf-8")
def _preparer(
*,
storage: FakeArchiveStorage | None = None,
archive_storage: FakeArchiveStorage | None = None,
download_storage: FakeArchiveStorage | None = None,
cache: FakeTaskCache,
bundles: list[WorkflowRunArchiveBundle] | None = None,
) -> WorkflowRunArchiveDownloadPreparer:
source_storage = archive_storage or storage
target_storage = download_storage or storage
assert source_storage is not None
assert target_storage is not None
session = MagicMock()
session.scalars.return_value = bundles or [_bundle()]
session_factory = MagicMock()
session_factory.return_value.__enter__.return_value = session
return WorkflowRunArchiveDownloadPreparer(
archive_storage=cast(ArchiveStorage, source_storage),
download_storage=cast(ArchiveStorage, target_storage),
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
session_factory=cast(sessionmaker[Session], session_factory),
)
def _parquet_bytes(records: list[dict[str, object]]) -> bytes:
buffer = io.BytesIO()
pq.write_table(pa.Table.from_pylist(records), buffer)
return buffer.getvalue()
def test_prepare_workflow_run_archive_download_builds_csv_zip_and_marks_ready() -> None:
bundle_refs = [(SHARD, "bundle-a"), (SHARD, "bundle-b")]
task = _task(bundle_refs)
first_bundle_payloads = {
"workflow_app_logs": _parquet_bytes([{"id": "log-a", "workflow_run_id": "run-a"}]),
"workflow_runs": _parquet_bytes([{"id": "run-a", "status": "succeeded", "error": "=1+1"}]),
}
second_bundle_payloads = {
"workflow_app_logs": _parquet_bytes([{"id": "log-b", "workflow_run_id": "run-b"}]),
"workflow_runs": _parquet_bytes([{"id": "run-b", "status": "failed", "error": "safe"}]),
}
archive_storage = FakeArchiveStorage(
{
f"{_object_prefix('bundle-a')}/manifest.json": _manifest_bytes(
first_bundle_payloads,
bundle_id="bundle-a",
),
**{
f"{_object_prefix('bundle-a')}/{table}.parquet": payload
for table, payload in first_bundle_payloads.items()
},
f"{_object_prefix('bundle-b')}/manifest.json": _manifest_bytes(
second_bundle_payloads,
bundle_id="bundle-b",
),
**{
f"{_object_prefix('bundle-b')}/{table}.parquet": payload
for table, payload in second_bundle_payloads.items()
},
}
)
download_storage = FakeArchiveStorage({})
cache = FakeTaskCache(task)
preparer = _preparer(
archive_storage=archive_storage,
download_storage=download_storage,
cache=cache,
bundles=[_bundle("bundle-a"), _bundle("bundle-b")],
)
result = preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id)
assert result is not None
assert result.status == WorkflowRunArchiveDownloadStatus.READY
assert result.storage_key == build_archive_download_storage_key(task)
assert result.file_name == "workflow-run-logs-2025-03.zip"
assert cache.saved_tasks[0].status == WorkflowRunArchiveDownloadStatus.PROCESSING
assert cache.saved_tasks[-1].status == WorkflowRunArchiveDownloadStatus.READY
assert archive_storage.put_objects == {}
archive_payload = download_storage.put_objects[result.storage_key]
with zipfile.ZipFile(io.BytesIO(archive_payload)) as archive:
names = set(archive.namelist())
assert names == {
"workflow-run-logs-2025-03/workflow_app_logs.csv",
"workflow-run-logs-2025-03/workflow_runs.csv",
}
workflow_runs_csv = archive.read("workflow-run-logs-2025-03/workflow_runs.csv").decode("utf-8")
assert workflow_runs_csv.count('"id","status","error"') == 1
assert '"run-a","succeeded","\'=1+1"' in workflow_runs_csv
assert '"run-b","failed","safe"' in workflow_runs_csv
def test_prepare_workflow_run_archive_download_marks_failed_on_checksum_mismatch() -> None:
task = _task()
table_payloads = {"workflow_runs": _parquet_bytes([{"id": "run-a", "status": "succeeded"}])}
manifest_data = json.loads(_manifest_bytes(table_payloads).decode("utf-8"))
manifest_data["tables"]["workflow_runs"]["checksum"] = "bad-checksum"
storage = FakeArchiveStorage(
{
MANIFEST_KEY: json.dumps(manifest_data).encode("utf-8"),
f"{OBJECT_PREFIX}/workflow_runs.parquet": table_payloads["workflow_runs"],
}
)
cache = FakeTaskCache(task)
preparer = _preparer(storage=storage, cache=cache)
result = preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id)
assert result is not None
assert result.status == WorkflowRunArchiveDownloadStatus.FAILED
assert "checksum mismatch" in (result.error or "")
assert storage.put_objects == {}
def test_prepare_workflow_run_archive_download_skips_duplicate_worker() -> None:
task = _task().model_copy(update={"celery_task_id": "celery-task-1"})
storage = FakeArchiveStorage({})
cache = FakeTaskCache(task)
preparer = _preparer(storage=storage, cache=cache, bundles=[])
nested_results: list[WorkflowRunArchiveDownloadTask | None] = []
preparer._get_task_bundles = MagicMock(return_value=[])
def build_payload(*_args: object) -> bytes:
nested_results.append(preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id))
return b"zip"
preparer._build_zip_payload = MagicMock(side_effect=build_payload)
result = preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id)
assert result is not None
assert result.status == WorkflowRunArchiveDownloadStatus.READY
assert nested_results[0] is not None
assert nested_results[0].status == WorkflowRunArchiveDownloadStatus.PROCESSING
preparer._build_zip_payload.assert_called_once()
def test_failed_worker_cannot_overwrite_ready_task() -> None:
processing_task = _task().model_copy(
update={"status": WorkflowRunArchiveDownloadStatus.PROCESSING, "celery_task_id": "celery-task-1"}
)
ready_task = processing_task.model_copy(update={"status": WorkflowRunArchiveDownloadStatus.READY})
cache = FakeTaskCache(ready_task)
preparer = _preparer(storage=FakeArchiveStorage({}), cache=cache)
result = preparer._mark_failed(processing_task, error="late failure")
assert result == ready_task
assert cache.task == ready_task
assert cache.saved_tasks == []
@@ -0,0 +1,189 @@
import datetime
from contextlib import nullcontext
import pytest
from services.retention.workflow_run.archive_download_task_cache import (
ARCHIVE_DOWNLOAD_FORMAT_VERSION,
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
WorkflowRunArchiveDownloadStatus,
WorkflowRunArchiveDownloadTask,
WorkflowRunArchiveDownloadTaskCache,
build_archive_download_id,
build_pending_archive_download_task,
)
class FakeRedis:
store: dict[str, tuple[int | datetime.timedelta, str]]
def __init__(self) -> None:
self.store = {}
self.lock_calls: list[tuple[str, float | None, float | None]] = []
def get(self, name: str | bytes) -> bytes | str | None:
key = name.decode("utf-8") if isinstance(name, bytes) else name
item = self.store.get(key)
return item[1] if item else None
def setex(self, name: str | bytes, time: int | datetime.timedelta, value: str) -> object:
key = name.decode("utf-8") if isinstance(name, bytes) else name
self.store[key] = (time, value)
return True
def delete(self, *names: str | bytes) -> object:
deleted = 0
for name in names:
key = name.decode("utf-8") if isinstance(name, bytes) else name
if self.store.pop(key, None) is not None:
deleted += 1
return deleted
def lock(
self,
name: str,
timeout: float | None = None,
blocking_timeout: float | None = None,
):
self.lock_calls.append((name, timeout, blocking_timeout))
return nullcontext()
def test_build_pending_archive_download_task_sets_ephemeral_payload() -> None:
now = datetime.datetime(2026, 6, 25, 8, 0, tzinfo=datetime.UTC)
task = build_pending_archive_download_task(
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=["bundle-a", "bundle-b"],
archive_bytes=1024,
ttl_seconds=3600,
download_id="download-1",
now=now,
)
assert task.status == WorkflowRunArchiveDownloadStatus.PENDING
assert task.bundle_count == 2
assert task.expires_at == now + datetime.timedelta(seconds=3600)
def test_build_archive_download_id_is_stable_for_same_bundle_set() -> None:
first = build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("01-of-02", "bundle-b"), ("00-of-02", "bundle-a")],
)
second = build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("00-of-02", "bundle-a"), ("01-of-02", "bundle-b")],
)
assert first == second
assert len(first) == 32
def test_build_archive_download_id_changes_when_content_or_format_changes() -> None:
base = build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("00-of-01", "bundle-a")],
)
changed_bundle = build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("00-of-01", "bundle-b")],
)
changed_format = build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("00-of-01", "bundle-a")],
download_format_version=f"{ARCHIVE_DOWNLOAD_FORMAT_VERSION}-next",
)
assert base != changed_bundle
assert base != changed_format
def test_build_archive_download_id_rejects_empty_bundle_refs() -> None:
with pytest.raises(ValueError, match="bundle_refs must not be empty"):
build_archive_download_id(tenant_id="tenant-1", year=2025, month=3, bundle_refs=[])
def test_archive_download_task_cache_round_trips_with_ttl() -> None:
redis = FakeRedis()
cache = WorkflowRunArchiveDownloadTaskCache(redis)
task = build_pending_archive_download_task(
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=["bundle-a"],
archive_bytes=1024,
ttl_seconds=3600,
download_id="download-1",
)
cache.save(task)
restored = cache.get(tenant_id="tenant-1", download_id="download-1")
assert restored == task
ttl, _ = redis.store["workflow_run_archive_download:tenant-1:download-1"]
assert isinstance(ttl, int)
assert 0 < ttl <= 3600
def test_archive_download_task_cache_uses_per_download_lock() -> None:
redis = FakeRedis()
cache = WorkflowRunArchiveDownloadTaskCache(redis)
with cache.lock(tenant_id="tenant-1", download_id="download-1"):
pass
assert redis.lock_calls == [
(
"workflow_run_archive_download:tenant-1:download-1:lock",
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
)
]
def test_archive_download_task_cache_delete_removes_entry() -> None:
redis = FakeRedis()
cache = WorkflowRunArchiveDownloadTaskCache(redis)
task = WorkflowRunArchiveDownloadTask(
download_id="download-1",
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=[],
bundle_count=0,
archive_bytes=0,
status=WorkflowRunArchiveDownloadStatus.FAILED,
error="failed",
created_at=datetime.datetime.now(datetime.UTC),
updated_at=datetime.datetime.now(datetime.UTC),
expires_at=datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=3600),
)
cache.save(task)
cache.delete(tenant_id="tenant-1", download_id="download-1")
assert cache.get(tenant_id="tenant-1", download_id="download-1") is None
def test_archive_download_task_cache_ignores_malformed_json() -> None:
redis = FakeRedis()
cache = WorkflowRunArchiveDownloadTaskCache(redis)
redis.setex("workflow_run_archive_download:tenant-1:download-1", 3600, "{")
assert cache.get(tenant_id="tenant-1", download_id="download-1") is None
@@ -0,0 +1,380 @@
import datetime
from contextlib import nullcontext
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
import pytest
from models.workflow import WorkflowRunArchiveBundle
from services.retention.workflow_run.archive_download_task_cache import (
WorkflowRunArchiveDownloadStatus,
WorkflowRunArchiveDownloadTask,
WorkflowRunArchiveDownloadTaskCache,
build_archive_download_id,
build_pending_archive_download_task,
)
from services.retention.workflow_run.archive_log_service import (
ArchiveDownloadTaskDispatcher,
WorkflowRunArchiveDownloadNotReadyError,
WorkflowRunArchiveNotFoundError,
create_workflow_run_archive_download_task,
get_ready_workflow_run_archive_download_task,
list_workflow_run_archives,
)
class FakeTaskCache:
saved_task: WorkflowRunArchiveDownloadTask | None
existing_task: WorkflowRunArchiveDownloadTask | None
tasks_by_download_id: dict[str, WorkflowRunArchiveDownloadTask]
def __init__(
self,
*,
existing_task: WorkflowRunArchiveDownloadTask | None = None,
tasks_by_download_id: dict[str, WorkflowRunArchiveDownloadTask] | None = None,
) -> None:
self.saved_task = None
self.existing_task = existing_task
self.tasks_by_download_id = tasks_by_download_id or {}
def lock(self, *, tenant_id: str, download_id: str):
return nullcontext()
def get(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
if self.tasks_by_download_id:
return self.tasks_by_download_id.get(download_id)
return self.existing_task
def save(self, task: WorkflowRunArchiveDownloadTask) -> None:
self.saved_task = task
self.existing_task = task
def _bundle(
*,
shard: str,
bundle_id: str,
archive_bytes: int,
year: int = 2025,
month: int = 3,
workflow_run_count: int = 1,
row_count: int = 9,
archived_at: datetime.datetime | None = None,
) -> WorkflowRunArchiveBundle:
return cast(
WorkflowRunArchiveBundle,
SimpleNamespace(
year=year,
month=month,
shard=shard,
bundle_id=bundle_id,
workflow_run_count=workflow_run_count,
row_count=row_count,
archive_bytes=archive_bytes,
archived_at=archived_at or datetime.datetime(2026, 6, 25, 8, 0),
),
)
def _fake_dispatcher(dispatched_tasks: list[WorkflowRunArchiveDownloadTask]) -> ArchiveDownloadTaskDispatcher:
def dispatch(
task: WorkflowRunArchiveDownloadTask,
cache: WorkflowRunArchiveDownloadTaskCache,
) -> WorkflowRunArchiveDownloadTask:
dispatched_tasks.append(task)
return task
return dispatch
def test_list_workflow_run_archives_aggregates_month_rows() -> None:
latest = datetime.datetime(2026, 6, 25, 8, 0)
previous = datetime.datetime(2026, 6, 24, 8, 0)
session = MagicMock()
march_download_id = build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("00-of-01", "bundle-a"), ("00-of-01", "bundle-b")],
)
ready_task = build_pending_archive_download_task(
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=["bundle-a", "bundle-b"],
bundle_refs=[("00-of-01", "bundle-a"), ("00-of-01", "bundle-b")],
archive_bytes=4096,
download_id=march_download_id,
).model_copy(
update={
"status": WorkflowRunArchiveDownloadStatus.READY,
"file_name": "workflow-run-logs-2025-03.zip",
"storage_key": "workflow-run-archive-downloads/tenant-1/2025/03/download.zip",
"file_size_bytes": 8192,
}
)
cache = FakeTaskCache(tasks_by_download_id={march_download_id: ready_task})
session.scalars.return_value = [
_bundle(
year=2025,
month=3,
shard="00-of-01",
bundle_id="bundle-a",
workflow_run_count=40,
row_count=360,
archive_bytes=1024,
archived_at=previous,
),
_bundle(
year=2025,
month=3,
shard="00-of-01",
bundle_id="bundle-b",
workflow_run_count=60,
row_count=540,
archive_bytes=3072,
archived_at=latest,
),
_bundle(
year=2025,
month=2,
shard="00-of-01",
bundle_id="bundle-c",
workflow_run_count=20,
row_count=180,
archive_bytes=1024,
archived_at=previous,
),
]
result = list_workflow_run_archives(session, "tenant-1", cache=cast(WorkflowRunArchiveDownloadTaskCache, cache))
assert result.summary.archived_month_count == 2
assert result.summary.workflow_run_count == 120
assert result.summary.archive_bytes == 5120
assert result.summary.latest_archived_at == latest
assert result.months[0].year == 2025
assert result.months[0].month == 3
assert result.months[0].bundle_count == 2
assert result.months[0].workflow_run_count == 100
assert result.months[0].row_count == 900
assert result.months[0].download_task == ready_task
assert result.months[1].download_task is None
def test_create_workflow_run_archive_download_task_creates_stable_pending_task() -> None:
session = MagicMock()
session.scalars.return_value = [
_bundle(shard="01-of-02", bundle_id="bundle-b", archive_bytes=2048),
_bundle(shard="00-of-02", bundle_id="bundle-a", archive_bytes=1024),
]
cache = FakeTaskCache()
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
task = create_workflow_run_archive_download_task(
session,
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
dispatcher=_fake_dispatcher(dispatched_tasks),
)
assert task.download_id == build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("01-of-02", "bundle-b"), ("00-of-02", "bundle-a")],
)
assert task.requested_by == "account-1"
assert task.bundle_ids == ["bundle-b", "bundle-a"]
assert [(ref.shard, ref.bundle_id) for ref in task.bundle_refs] == [
("01-of-02", "bundle-b"),
("00-of-02", "bundle-a"),
]
assert task.archive_bytes == 3072
assert cache.saved_task == dispatched_tasks[0]
assert task.celery_task_id is not None
def test_create_workflow_run_archive_download_task_returns_existing_task_when_cache_key_exists() -> None:
session = MagicMock()
session.scalars.return_value = [_bundle(shard="00-of-01", bundle_id="bundle-a", archive_bytes=1024)]
existing_task = build_pending_archive_download_task(
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=["bundle-a"],
archive_bytes=1024,
download_id="existing-download",
).model_copy(update={"celery_task_id": "celery-task-1"})
cache = FakeTaskCache(existing_task=existing_task)
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
task = create_workflow_run_archive_download_task(
session,
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
dispatcher=_fake_dispatcher(dispatched_tasks),
)
assert task == existing_task
assert cache.saved_task is None
assert dispatched_tasks == []
def test_create_workflow_run_archive_download_task_retries_failed_cached_task() -> None:
session = MagicMock()
session.scalars.return_value = [_bundle(shard="00-of-01", bundle_id="bundle-a", archive_bytes=1024)]
existing_task = build_pending_archive_download_task(
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=["bundle-a"],
bundle_refs=[("00-of-01", "bundle-a")],
archive_bytes=1024,
download_id=build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("00-of-01", "bundle-a")],
),
).model_copy(update={"status": WorkflowRunArchiveDownloadStatus.FAILED, "error": "failed"})
cache = FakeTaskCache(existing_task=existing_task)
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
task = create_workflow_run_archive_download_task(
session,
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
dispatcher=_fake_dispatcher(dispatched_tasks),
)
assert task.status == WorkflowRunArchiveDownloadStatus.PENDING
assert task.error is None
assert task.celery_task_id is not None
assert cache.saved_task == dispatched_tasks[0]
@pytest.mark.parametrize("retry_failed_task", [False, True])
def test_create_workflow_run_archive_download_task_claims_dispatch_once(retry_failed_task: bool) -> None:
session = MagicMock()
session.scalars.return_value = [_bundle(shard="00-of-01", bundle_id="bundle-a", archive_bytes=1024)]
download_id = build_archive_download_id(
tenant_id="tenant-1",
year=2025,
month=3,
bundle_refs=[("00-of-01", "bundle-a")],
)
existing_task = None
if retry_failed_task:
existing_task = build_pending_archive_download_task(
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=["bundle-a"],
bundle_refs=[("00-of-01", "bundle-a")],
archive_bytes=1024,
download_id=download_id,
).model_copy(update={"status": WorkflowRunArchiveDownloadStatus.FAILED})
cache = FakeTaskCache(existing_task=existing_task)
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
concurrent_results: list[WorkflowRunArchiveDownloadTask] = []
def dispatch(
task: WorkflowRunArchiveDownloadTask,
task_cache: WorkflowRunArchiveDownloadTaskCache,
) -> WorkflowRunArchiveDownloadTask:
dispatched_tasks.append(task)
concurrent_results.append(
create_workflow_run_archive_download_task(
session,
tenant_id="tenant-1",
requested_by="account-2",
year=2025,
month=3,
cache=task_cache,
dispatcher=dispatch,
)
)
return task
result = create_workflow_run_archive_download_task(
session,
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
dispatcher=dispatch,
)
assert dispatched_tasks == [result]
assert concurrent_results == [result]
def test_create_workflow_run_archive_download_task_rejects_missing_month() -> None:
session = MagicMock()
session.scalars.return_value = []
with pytest.raises(WorkflowRunArchiveNotFoundError):
create_workflow_run_archive_download_task(
session,
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
cache=cast(WorkflowRunArchiveDownloadTaskCache, FakeTaskCache()),
dispatcher=_fake_dispatcher([]),
)
def test_get_ready_workflow_run_archive_download_task_requires_ready_file() -> None:
pending_task = build_pending_archive_download_task(
tenant_id="tenant-1",
requested_by="account-1",
year=2025,
month=3,
bundle_ids=["bundle-a"],
archive_bytes=1024,
download_id="download-1",
)
cache = FakeTaskCache(existing_task=pending_task)
with pytest.raises(WorkflowRunArchiveDownloadNotReadyError):
get_ready_workflow_run_archive_download_task(
tenant_id="tenant-1",
download_id="download-1",
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
)
ready_task = pending_task.model_copy(
update={
"status": WorkflowRunArchiveDownloadStatus.READY,
"storage_key": "downloads/download-1.zip",
"file_name": "workflow-run-logs-2025-03.zip",
}
)
cache = FakeTaskCache(existing_task=ready_task)
assert (
get_ready_workflow_run_archive_download_task(
tenant_id="tenant-1",
download_id="download-1",
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
)
== ready_task
)
@@ -78,5 +78,9 @@ export const contractLoaders = {
workflow: () => import('./workflow/orpc.gen').then(({ workflow }) => ({ workflow })),
workflowGenerate: () =>
import('./workflow-generate/orpc.gen').then(({ workflowGenerate }) => ({ workflowGenerate })),
workflowRunArchives: () =>
import('./workflow-run-archives/orpc.gen').then(({ workflowRunArchives }) => ({
workflowRunArchives,
})),
workspaces: () => import('./workspaces/orpc.gen').then(({ workspaces }) => ({ workspaces })),
}
@@ -52,6 +52,7 @@ import { trialModels } from './trial-models/orpc.gen'
import { version } from './version/orpc.gen'
import { website } from './website/orpc.gen'
import { workflowGenerate } from './workflow-generate/orpc.gen'
import { workflowRunArchives } from './workflow-run-archives/orpc.gen'
import { workflow } from './workflow/orpc.gen'
import { workspaces } from './workspaces/orpc.gen'
@@ -108,6 +109,7 @@ const communityContract = {
website,
workflow,
workflowGenerate,
workflowRunArchives,
workspaces,
}
@@ -0,0 +1,74 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zGetWorkflowRunArchivesDownloadsByDownloadIdPath,
zGetWorkflowRunArchivesDownloadsByDownloadIdResponse,
zGetWorkflowRunArchivesResponse,
zPostWorkflowRunArchivesDownloadsBody,
zPostWorkflowRunArchivesDownloadsResponse,
} from './zod.gen'
/**
* Get a temporary workflow-run archive download task
*/
export const get = oc
.route({
description: 'Get a temporary workflow-run archive download task',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkflowRunArchivesDownloadsByDownloadId',
path: '/workflow-run-archives/downloads/{download_id}',
tags: ['console'],
})
.input(z.object({ params: zGetWorkflowRunArchivesDownloadsByDownloadIdPath }))
.output(zGetWorkflowRunArchivesDownloadsByDownloadIdResponse)
export const byDownloadId = {
get,
}
/**
* Create or return a temporary workflow-run archive download task
*/
export const post = oc
.route({
description: 'Create or return a temporary workflow-run archive download task',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkflowRunArchivesDownloads',
path: '/workflow-run-archives/downloads',
successStatus: 202,
tags: ['console'],
})
.input(z.object({ body: zPostWorkflowRunArchivesDownloadsBody }))
.output(zPostWorkflowRunArchivesDownloadsResponse)
export const downloads = {
post,
byDownloadId,
}
/**
* List monthly workflow-run archive metadata for the current workspace
*/
export const get2 = oc
.route({
description: 'List monthly workflow-run archive metadata for the current workspace',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkflowRunArchives',
path: '/workflow-run-archives',
tags: ['console'],
})
.output(zGetWorkflowRunArchivesResponse)
export const workflowRunArchives = {
get: get2,
downloads,
}
export const contract = {
workflowRunArchives,
}
@@ -0,0 +1,96 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type WorkflowRunArchiveListResponse = {
months: Array<WorkflowRunArchiveMonthResponse>
summary: WorkflowRunArchiveSummaryResponse
}
export type WorkflowRunArchiveDownloadPayload = {
month: number
year: number
}
export type WorkflowRunArchiveDownloadTaskResponse = {
archive_bytes: number
bundle_count: number
created_at: string
download_id: string
error?: string | null
expires_at: string
file_name?: string | null
file_size_bytes?: number | null
finished_at?: string | null
month: number
started_at?: string | null
status: WorkflowRunArchiveDownloadStatus
updated_at: string
year: number
}
export type WorkflowRunArchiveMonthResponse = {
archive_bytes: number
bundle_count: number
download_task?: WorkflowRunArchiveDownloadTaskResponse | null
latest_archived_at: string
month: number
row_count: number
workflow_run_count: number
year: number
}
export type WorkflowRunArchiveSummaryResponse = {
archive_bytes: number
archived_month_count: number
latest_archived_at?: string | null
workflow_run_count: number
}
export type WorkflowRunArchiveDownloadStatus = 'failed' | 'pending' | 'processing' | 'ready'
export type GetWorkflowRunArchivesData = {
body?: never
path?: never
query?: never
url: '/workflow-run-archives'
}
export type GetWorkflowRunArchivesResponses = {
200: WorkflowRunArchiveListResponse
}
export type GetWorkflowRunArchivesResponse =
GetWorkflowRunArchivesResponses[keyof GetWorkflowRunArchivesResponses]
export type PostWorkflowRunArchivesDownloadsData = {
body: WorkflowRunArchiveDownloadPayload
path?: never
query?: never
url: '/workflow-run-archives/downloads'
}
export type PostWorkflowRunArchivesDownloadsResponses = {
202: WorkflowRunArchiveDownloadTaskResponse
}
export type PostWorkflowRunArchivesDownloadsResponse =
PostWorkflowRunArchivesDownloadsResponses[keyof PostWorkflowRunArchivesDownloadsResponses]
export type GetWorkflowRunArchivesDownloadsByDownloadIdData = {
body?: never
path: {
download_id: string
}
query?: never
url: '/workflow-run-archives/downloads/{download_id}'
}
export type GetWorkflowRunArchivesDownloadsByDownloadIdResponses = {
200: WorkflowRunArchiveDownloadTaskResponse
}
export type GetWorkflowRunArchivesDownloadsByDownloadIdResponse =
GetWorkflowRunArchivesDownloadsByDownloadIdResponses[keyof GetWorkflowRunArchivesDownloadsByDownloadIdResponses]
@@ -0,0 +1,99 @@
// This file is auto-generated by @hey-api/openapi-ts
import * as z from 'zod'
/**
* WorkflowRunArchiveDownloadPayload
*
* Request body for preparing one monthly workflow-run archive download.
*/
export const zWorkflowRunArchiveDownloadPayload = z.object({
month: z.int().gte(1).lte(12),
year: z.int().gte(1),
})
/**
* WorkflowRunArchiveSummaryResponse
*/
export const zWorkflowRunArchiveSummaryResponse = z.object({
archive_bytes: z.int(),
archived_month_count: z.int(),
latest_archived_at: z.iso.datetime().nullish(),
workflow_run_count: z.int(),
})
/**
* WorkflowRunArchiveDownloadStatus
*
* Lifecycle state for an asynchronous archive download request.
*/
export const zWorkflowRunArchiveDownloadStatus = z.enum([
'failed',
'pending',
'processing',
'ready',
])
/**
* WorkflowRunArchiveDownloadTaskResponse
*/
export const zWorkflowRunArchiveDownloadTaskResponse = z.object({
archive_bytes: z.int(),
bundle_count: z.int(),
created_at: z.iso.datetime(),
download_id: z.string(),
error: z.string().nullish(),
expires_at: z.iso.datetime(),
file_name: z.string().nullish(),
file_size_bytes: z.int().nullish(),
finished_at: z.iso.datetime().nullish(),
month: z.int(),
started_at: z.iso.datetime().nullish(),
status: zWorkflowRunArchiveDownloadStatus,
updated_at: z.iso.datetime(),
year: z.int(),
})
/**
* WorkflowRunArchiveMonthResponse
*/
export const zWorkflowRunArchiveMonthResponse = z.object({
archive_bytes: z.int(),
bundle_count: z.int(),
download_task: zWorkflowRunArchiveDownloadTaskResponse.nullish(),
latest_archived_at: z.iso.datetime(),
month: z.int(),
row_count: z.int(),
workflow_run_count: z.int(),
year: z.int(),
})
/**
* WorkflowRunArchiveListResponse
*/
export const zWorkflowRunArchiveListResponse = z.object({
months: z.array(zWorkflowRunArchiveMonthResponse),
summary: zWorkflowRunArchiveSummaryResponse,
})
/**
* Success
*/
export const zGetWorkflowRunArchivesResponse = zWorkflowRunArchiveListResponse
export const zPostWorkflowRunArchivesDownloadsBody = zWorkflowRunArchiveDownloadPayload
/**
* Download task accepted
*/
export const zPostWorkflowRunArchivesDownloadsResponse = zWorkflowRunArchiveDownloadTaskResponse
export const zGetWorkflowRunArchivesDownloadsByDownloadIdPath = z.object({
download_id: z.string(),
})
/**
* Success
*/
export const zGetWorkflowRunArchivesDownloadsByDownloadIdResponse =
zWorkflowRunArchiveDownloadTaskResponse
@@ -0,0 +1,82 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { ArchivedLogsNotice } from '../archived-logs-notice'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
vi.mock('@/context/workspace-state', () => ({ isCurrentWorkspaceManagerAtom: {} }))
vi.mock('jotai', () => ({ useAtomValue: () => true }))
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return {
...actual,
useModalContextSelector: vi.fn(),
}
})
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContextSelector = vi.mocked(useModalContextSelector)
function mockProviderPlan(planType: Plan) {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({
enableBilling: true,
plan: {
...defaultPlan,
type: planType,
},
}),
)
}
describe('ArchivedLogsNotice', () => {
const setShowAccountSettingModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockProviderPlan(Plan.professional)
mockUseModalContextSelector.mockImplementation((selector) =>
selector({
setShowAccountSettingModal,
} as unknown as Parameters<typeof selector>[0]),
)
})
it('should show notice for paid workspace managers', () => {
render(<ArchivedLogsNotice />)
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
})
})
it('should not show notice for sandbox workspaces', () => {
mockProviderPlan(Plan.sandbox)
render(<ArchivedLogsNotice />)
expect(screen.queryByText('appLog.archives.notice.description')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,11 @@
const LAST_THREE_MONTHS_PERIOD = '4'
type TimePeriodMapping = Record<string, { value: number }>
export function shouldShowArchivedLogsNotice(period: string, periodMapping: TimePeriodMapping) {
const periodValue = periodMapping[period]?.value
const lastThreeMonthsValue = periodMapping[LAST_THREE_MONTHS_PERIOD]?.value
if (periodValue === undefined || lastThreeMonthsValue === undefined) return false
return periodValue < 0 || periodValue > lastThreeMonthsValue
}
@@ -0,0 +1,50 @@
'use client'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
export function ArchivedLogsNotice() {
const { t } = useTranslation()
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { enableBilling, plan } = useProviderContext()
const setShowAccountSettingModal = useModalContextSelector(
(state) => state.setShowAccountSettingModal,
)
if (
!IS_CLOUD_EDITION ||
!isCurrentWorkspaceManager ||
!enableBilling ||
plan.type === Plan.sandbox
)
return null
return (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2">
<span
aria-hidden
className="mt-0.5 i-ri-information-line size-4 shrink-0 text-util-colors-warning-warning-600"
/>
<div className="min-w-0 flex-1 system-xs-regular text-util-colors-warning-warning-700">
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
<button
type="button"
className="ml-1 system-xs-semibold text-util-colors-warning-warning-700 underline underline-offset-2 hover:text-text-primary"
onClick={() =>
setShowAccountSettingModal({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
})
}
>
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
</button>
</div>
</div>
)
}
@@ -17,6 +17,8 @@ import { APP_PAGE_LIMIT } from '@/config'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useWorkflowLogs } from '@/service/use-log'
import PageTitle from '../log-annotation/page-title'
import { ArchivedLogsNotice } from '../log/archived-logs-notice'
import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils'
import Filter, { TIME_PERIOD_MAPPING } from './filter'
import List from './list'
@@ -69,6 +71,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
})
const total = workflowLogs?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const showArchivedLogsNotice = shouldShowArchivedLogsNotice(
queryParams.period,
TIME_PERIOD_MAPPING,
)
return (
<div className="flex h-full flex-col">
@@ -78,6 +84,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
/>
<div className="flex max-h-[calc(100%-16px)] flex-1 flex-col py-4">
<Filter queryParams={queryParams} setQueryParams={setQueryParams} />
{showArchivedLogsNotice && <ArchivedLogsNotice />}
{/* workflow log */}
{total === undefined ? (
<Loading type="app" />
@@ -16,6 +16,7 @@ describe('AccountSetting Constants', () => {
expect(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS).toBe('roles-and-permissions')
expect(ACCOUNT_SETTING_TAB.PERMISSION_SET).toBe('permission-set')
expect(ACCOUNT_SETTING_TAB.BILLING).toBe('billing')
expect(ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES).toBe('workflow-log-archives')
expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('custom-endpoint')
expect(ACCOUNT_SETTING_TAB.CUSTOM).toBe('custom')
@@ -31,6 +32,7 @@ describe('AccountSetting Constants', () => {
expect(isValidSettingsTab('roles-and-permissions')).toBe(true)
expect(isValidSettingsTab('permission-set')).toBe(true)
expect(isValidSettingsTab('billing')).toBe(true)
expect(isValidSettingsTab('workflow-log-archives')).toBe(true)
expect(isValidSettingsTab('preferences')).toBe(true)
expect(isValidSettingsTab('language')).toBe(true)
expect(isValidSettingsTab('provider')).toBe(true)
@@ -9,10 +9,22 @@ import { ACCOUNT_SETTING_TAB } from '../constants'
import AccountSetting from '../index'
const mockResetModelProviderListExpanded = vi.fn()
const mockConfig = vi.hoisted(() => ({
IS_CLOUD_EDITION: true,
}))
const mockAppContextState = vi.hoisted(() => ({
current: null as unknown,
}))
const mockUseAppContext = vi.hoisted(() => vi.fn())
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
}
})
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
@@ -240,12 +252,12 @@ describe('AccountSetting', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: true,
enableReplaceWebAppLogo: true,
})
mockUseAppContext.mockReturnValue(baseAppContextValue)
mockAppContextState.current = baseAppContextValue
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
})
@@ -267,9 +279,15 @@ describe('AccountSetting', () => {
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
).toBeInTheDocument()
expect(screen.getByText('common.settings.billing'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
expect(screen.queryByText('common.settings.dataSource'))!.not.toBeInTheDocument()
expect(screen.queryByText('common.settings.customEndpoint'))!.not.toBeInTheDocument()
expect(screen.getByText('custom.custom'))!.toBeInTheDocument()
expect(
screen
.getByRole('button', { name: 'custom.custom' })
.compareDocumentPosition(screen.getByRole('button', { name: 'appLog.archives.title' })),
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
})
@@ -372,7 +390,6 @@ describe('AccountSetting', () => {
...baseAppContextValue,
isCurrentWorkspaceDatasetOperator: true,
}
mockUseAppContext.mockReturnValue(datasetOperatorContext)
mockAppContextState.current = datasetOperatorContext
// Act
@@ -387,6 +404,7 @@ describe('AccountSetting', () => {
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.settings.preferences' }),
@@ -401,7 +419,6 @@ describe('AccountSetting', () => {
(key) => key !== 'api_extension.manage',
),
}
mockUseAppContext.mockReturnValue(contextWithoutApiExtensionPermission)
mockAppContextState.current = contextWithoutApiExtensionPermission
// Act
@@ -421,7 +438,6 @@ describe('AccountSetting', () => {
(key) => key !== 'customization.manage',
),
}
mockUseAppContext.mockReturnValue(contextWithoutCustomizationPermission)
mockAppContextState.current = contextWithoutCustomizationPermission
// Act
@@ -430,6 +446,7 @@ describe('AccountSetting', () => {
// Assert
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
})
@@ -442,7 +459,6 @@ describe('AccountSetting', () => {
(key) => key !== 'workspace.role.manage',
),
}
mockUseAppContext.mockReturnValue(contextWithoutRoleManagePermission)
mockAppContextState.current = contextWithoutRoleManagePermission
// Act
@@ -507,7 +523,6 @@ describe('AccountSetting', () => {
(key) => key !== 'billing.view',
),
}
mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission)
mockAppContextState.current = contextWithoutBillingViewPermission
// Act
@@ -519,6 +534,57 @@ describe('AccountSetting', () => {
).not.toBeInTheDocument()
})
it('should hide workflow log archives outside cloud edition', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = false
// Act
renderAccountSetting()
// Assert
expect(
screen.queryByRole('button', { name: 'appLog.archives.title' }),
).not.toBeInTheDocument()
})
it('should hide workflow log archives from custom RBAC roles that are not owner or admin', () => {
// Arrange
const contextWithRoleManagePermissionButNotManager = {
...baseAppContextValue,
currentWorkspace: {
...baseAppContextValue.currentWorkspace,
role: 'normal' as const,
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
workspacePermissionKeys: [
...(baseAppContextValue.workspacePermissionKeys ?? []),
'workspace.role.manage',
],
}
mockAppContextState.current = contextWithRoleManagePermissionButNotManager
// Act
renderAccountSetting()
// Assert
expect(
screen.queryByRole('button', { name: 'appLog.archives.title' }),
).not.toBeInTheDocument()
})
it('should not render workflow log archives page outside cloud edition', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = false
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES })
// Assert
expect(screen.queryByTestId('workflow-log-archives-page')).not.toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
})
it('should not render billing page when active billing tab lacks billing view permission', () => {
// Arrange
const contextWithoutBillingViewPermission = {
@@ -527,7 +593,6 @@ describe('AccountSetting', () => {
(key) => key !== 'billing.view',
),
}
mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission)
mockAppContextState.current = contextWithoutBillingViewPermission
// Act
@@ -566,6 +631,10 @@ describe('AccountSetting', () => {
// Custom Page uses 'custom.custom' key as well.
expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
// Workflow Log Archives
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.title' }))
expect(screen.getByTestId('workflow-log-archives-page')).toBeInTheDocument()
// Members
fireEvent.click(screen.getAllByText('common.settings.members')[0]!)
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
@@ -9,6 +9,7 @@ export const ACCOUNT_SETTING_TAB = {
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
PERMISSION_SET: 'permission-set',
BILLING: 'billing',
WORKFLOW_LOG_ARCHIVES: 'workflow-log-archives',
DATA_SOURCE: 'data-source',
API_BASED_EXTENSION: 'custom-endpoint',
CUSTOM: 'custom',
@@ -25,6 +26,7 @@ const WORKSPACE_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
ACCOUNT_SETTING_TAB.PERMISSION_SET,
ACCOUNT_SETTING_TAB.BILLING,
ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
ACCOUNT_SETTING_TAB.CUSTOM,
] as const
@@ -11,8 +11,10 @@ import BillingPage from '@/app/components/billing/billing-page'
import CustomPage from '@/app/components/custom/custom-page'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import { IS_CLOUD_EDITION } from '@/config'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { BillingPermission, hasPermission } from '@/utils/permission'
@@ -24,6 +26,7 @@ import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
import PermissionsPage from './permissions-page'
import PreferencePage from './preference-page'
import WorkflowLogArchivesPage from './workflow-log-archives-page'
const iconClassName = `
w-4 h-4 mr-2
@@ -54,17 +57,24 @@ export default function AccountSetting({
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const canManageWorkspaceRoles =
isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const canViewBilling =
enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)
const canViewWorkflowLogArchives = IS_CLOUD_EDITION && isCurrentWorkspaceManager
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
const normalizedActiveTab =
activeTab === ACCOUNT_SETTING_TAB.LANGUAGE ? ACCOUNT_SETTING_TAB.PREFERENCES : activeTab
const activeMenu = (() => {
if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.PREFERENCES
if (
normalizedActiveTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES &&
!canViewWorkflowLogArchives
)
return ACCOUNT_SETTING_TAB.MEMBERS
if (
(normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS ||
normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) &&
@@ -108,6 +118,13 @@ export default function AccountSetting({
icon: <span className={cn('i-ri-money-dollar-circle-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-money-dollar-circle-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
name: t(($) => $['archives.title'], { ns: 'appLog' }),
description: t(($) => $['archives.description'], { ns: 'appLog' }),
icon: <span className={cn('i-ri-archive-drawer-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-archive-drawer-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.DATA_SOURCE,
name: t(($) => $['settings.dataSource'], { ns: 'common' }),
@@ -150,6 +167,8 @@ export default function AccountSetting({
if (enableReplaceWebAppLogo || enableBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.CUSTOM)
if (canViewWorkflowLogArchives) visibleTabs.push(ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES)
return visibleTabs
.map((tab) => settingItems.find((item) => item.key === tab))
.filter((item): item is GroupItem => Boolean(item))
@@ -276,6 +295,9 @@ export default function AccountSetting({
)}
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && <AccessRulesPage />}
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
{activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && (
<WorkflowLogArchivesPage />
)}
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
{activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && <CustomPage />}
@@ -0,0 +1,134 @@
import type { GetWorkflowRunArchivesResponse } from '@dify/contracts/api/console/workflow-run-archives/types.gen'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
createTestQueryClient,
renderWithSystemFeatures,
} from '@/__tests__/utils/mock-system-features'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import WorkflowLogArchivesPage from '../index'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return {
...actual,
useModalContext: vi.fn(),
}
})
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContext = vi.mocked(useModalContext)
const archiveData: GetWorkflowRunArchivesResponse = {
summary: {
archived_month_count: 1,
workflow_run_count: 125,
archive_bytes: 1048576,
latest_archived_at: '2025-03-03T00:00:00Z',
},
months: [
{
year: 2025,
month: 3,
workflow_run_count: 125,
row_count: 1125,
archive_bytes: 1048576,
bundle_count: 2,
latest_archived_at: '2025-03-03T00:00:00Z',
download_task: null,
},
],
}
function mockPlan(planType: Plan.sandbox | Plan.professional) {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({
enableBilling: true,
plan: {
...defaultPlan,
type: planType,
},
}),
)
}
function renderPage() {
const queryClient = createTestQueryClient()
queryClient.setQueryData(consoleQuery.workflowRunArchives.get.queryKey(), archiveData)
return renderWithSystemFeatures(<WorkflowLogArchivesPage />, {
queryClient,
})
}
describe('WorkflowLogArchivesPage', () => {
const setShowPricingModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockPlan(Plan.professional)
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
})
describe('Plan access', () => {
it('should show upgrade guidance instead of archive content for sandbox workspaces', () => {
// Arrange
mockPlan(Plan.sandbox)
// Act
renderPage()
// Assert
expect(screen.getByText('appLog.archives.upgradeTip.title')).toBeInTheDocument()
expect(screen.queryByText('2025-03')).not.toBeInTheDocument()
})
it('should open pricing modal from the sandbox upgrade guidance', () => {
// Arrange
mockPlan(Plan.sandbox)
renderPage()
// Act
fireEvent.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }))
// Assert
expect(setShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should show archive content for paid workspaces', () => {
// Arrange
mockPlan(Plan.professional)
// Act
renderPage()
// Assert
expect(screen.queryByText('appLog.archives.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.getByText('2025-03')).toBeInTheDocument()
expect(screen.getAllByText('125').length).toBeGreaterThan(0)
})
})
})
@@ -0,0 +1,420 @@
'use client'
import type {
WorkflowRunArchiveDownloadStatus,
WorkflowRunArchiveDownloadTaskResponse,
WorkflowRunArchiveMonthResponse,
} from '@dify/contracts/api/console/workflow-run-archives/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { Plan } from '@/app/components/billing/type'
import { API_PREFIX, IS_CLOUD_EDITION } from '@/config'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
const numberFormatter = new Intl.NumberFormat()
const byteFormatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
})
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
const DOWNLOAD_TASK_POLLING_INTERVAL = 3000
const ARCHIVE_MONTH_PAGE_SIZE = 20
function formatNumber(value: number) {
return numberFormatter.format(value)
}
function formatBytes(bytes: number) {
if (bytes <= 0) return '0 B'
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
value /= 1024
unitIndex += 1
}
return `${byteFormatter.format(value)} ${BYTE_UNITS[unitIndex]}`
}
function formatMonth(year: number, month: number) {
return `${year}-${String(month).padStart(2, '0')}`
}
function formatDate(value: string | null | undefined) {
if (!value) return '-'
return value.slice(0, 10)
}
function isPreparingStatus(status: WorkflowRunArchiveDownloadStatus | undefined) {
return status === 'pending' || status === 'processing'
}
function buildArchiveDownloadFileUrl(downloadId: string) {
return `${API_PREFIX}/workflow-run-archives/downloads/${downloadId}/file`
}
const tableGridClassName = 'grid-cols-[0.66fr_0.78fr_0.78fr_1fr]'
export default function WorkflowLogArchivesPage() {
const { t } = useTranslation()
const { plan, enableBilling } = useProviderContext()
const [visibleArchiveMonthCount, setVisibleArchiveMonthCount] = useState(ARCHIVE_MONTH_PAGE_SIZE)
const loadMoreRef = useRef<HTMLDivElement | null>(null)
const canViewArchiveContent = IS_CLOUD_EDITION && enableBilling && plan.type !== Plan.sandbox
const archiveListQuery = useQuery(
consoleQuery.workflowRunArchives.get.queryOptions({
enabled: canViewArchiveContent,
}),
)
const archiveData = archiveListQuery.data
const archiveMonths = archiveData?.months ?? []
const visibleArchiveMonths = archiveMonths.slice(0, visibleArchiveMonthCount)
const summary = archiveData?.summary
const isLoading = archiveListQuery.isLoading
const hasMoreArchives = visibleArchiveMonths.length < archiveMonths.length
useEffect(() => {
if (!hasMoreArchives) return
const loadMoreElement = loadMoreRef.current
if (!loadMoreElement) return
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return
setVisibleArchiveMonthCount((count) =>
Math.min(count + ARCHIVE_MONTH_PAGE_SIZE, archiveMonths.length),
)
},
{ rootMargin: '120px' },
)
observer.observe(loadMoreElement)
return () => observer.disconnect()
}, [archiveMonths.length, hasMoreArchives, visibleArchiveMonthCount])
const summaryItems = [
{
label: t(($) => $['archives.summary.months'], { ns: 'appLog' }),
value: summary ? formatNumber(summary.archived_month_count) : '0',
icon: 'i-ri-calendar-2-line',
},
{
label: t(($) => $['archives.summary.runs'], { ns: 'appLog' }),
value: summary ? formatNumber(summary.workflow_run_count) : '0',
icon: 'i-ri-git-branch-line',
},
{
label: t(($) => $['archives.summary.size'], { ns: 'appLog' }),
value: summary ? formatBytes(summary.archive_bytes) : '0 B',
icon: 'i-ri-hard-drive-2-line',
},
{
label: t(($) => $['archives.summary.latest'], { ns: 'appLog' }),
value: formatDate(summary?.latest_archived_at),
icon: 'i-ri-time-line',
},
]
if (!canViewArchiveContent) {
return (
<div data-testid="workflow-log-archives-page" className="pb-6">
<ArchivedLogsUpgradeBanner />
</div>
)
}
return (
<div data-testid="workflow-log-archives-page" className="flex flex-col gap-4 pb-6">
<div className="rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn p-2">
<div className="grid grid-cols-2 gap-1 lg:grid-cols-4">
{summaryItems.map((item) => (
<div
key={item.label}
className="flex min-h-[92px] flex-col gap-2 rounded-xl bg-components-panel-bg p-4"
>
<span className={cn(item.icon, 'size-4 text-text-tertiary')} aria-hidden="true" />
<div className="system-xs-medium text-text-tertiary">{item.label}</div>
<div className="flex min-h-6 items-center">
{isLoading ? (
<SkeletonRectangle className="h-5 w-20 animate-pulse rounded-md" />
) : (
<div className="system-md-semibold whitespace-nowrap text-text-primary">
{item.value}
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs">
<div className="overflow-x-auto">
<div className="min-w-[460px]">
<div
className={cn(
'grid h-8 items-center gap-3 border-b border-divider-subtle bg-background-section-burn px-4 system-xs-medium-uppercase text-text-tertiary',
tableGridClassName,
)}
>
<div className="text-center">
{t(($) => $['archives.table.month'], { ns: 'appLog' })}
</div>
<div className="text-center">
{t(($) => $['archives.table.runs'], { ns: 'appLog' })}
</div>
<div className="text-center">
{t(($) => $['archives.table.size'], { ns: 'appLog' })}
</div>
<div className="text-center">
{t(($) => $['archives.table.action'], { ns: 'appLog' })}
</div>
</div>
{isLoading && (
<>
{['first', 'second', 'third'].map((key) => (
<div
key={key}
className={cn(
'grid min-h-15 items-center gap-3 border-b border-divider-subtle px-4 py-3 last:border-b-0',
tableGridClassName,
)}
>
<div className="flex justify-center">
<SkeletonRectangle className="h-4 w-16 animate-pulse" />
</div>
<div className="flex justify-center">
<SkeletonRectangle className="h-4 w-16 animate-pulse" />
</div>
<div className="flex justify-center">
<SkeletonRectangle className="h-4 w-14 animate-pulse" />
</div>
<div className="flex justify-center">
<SkeletonRectangle className="h-8 w-24 animate-pulse rounded-lg" />
</div>
</div>
))}
</>
)}
{!isLoading && archiveListQuery.isError && (
<div className="flex min-h-36 flex-col items-center justify-center gap-2 px-4 text-center">
<span
className="i-ri-error-warning-line size-6 text-text-tertiary"
aria-hidden="true"
/>
<div className="system-sm-semibold text-text-secondary">
{t(($) => $['archives.error.title'], { ns: 'appLog' })}
</div>
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['archives.error.description'], { ns: 'appLog' })}
</div>
</div>
)}
{!isLoading && !archiveListQuery.isError && archiveMonths.length === 0 && (
<div className="flex min-h-36 flex-col items-center justify-center gap-2 px-4 text-center">
<span className="i-ri-archive-line size-6 text-text-tertiary" aria-hidden="true" />
<div className="system-sm-semibold text-text-secondary">
{t(($) => $['archives.empty.title'], { ns: 'appLog' })}
</div>
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['archives.empty.description'], { ns: 'appLog' })}
</div>
</div>
)}
{!isLoading &&
!archiveListQuery.isError &&
visibleArchiveMonths.map((archive) => {
const archiveMonth = formatMonth(archive.year, archive.month)
return <WorkflowArchiveMonthRow key={archiveMonth} archive={archive} />
})}
{!isLoading && !archiveListQuery.isError && hasMoreArchives && (
<div
ref={loadMoreRef}
className="flex h-10 items-center justify-center border-t border-divider-subtle bg-components-card-bg"
aria-hidden="true"
>
<SkeletonRectangle className="h-4 w-20 animate-pulse rounded-md" />
</div>
)}
</div>
</div>
</div>
</div>
)
}
function ArchivedLogsUpgradeBanner() {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
return (
<div className="flex flex-col gap-4 rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-4 pl-6 shadow-lg backdrop-blur-xs sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1 text-text-primary-on-surface">
<div className="title-xl-semi-bold">
{t(($) => $['archives.upgradeTip.title'], { ns: 'appLog' })}
</div>
<div className="system-sm-regular">
{t(($) => $['archives.upgradeTip.description'], { ns: 'appLog' })}
</div>
</div>
<button
type="button"
className="flex h-10 w-[120px] shrink-0 cursor-pointer items-center justify-center rounded-3xl border-none bg-white p-0 system-md-semibold text-text-accent shadow-xs hover:opacity-95"
onClick={() => setShowPricingModal()}
>
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</button>
</div>
)
}
function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonthResponse }) {
const { t } = useTranslation()
const [downloadTask, setDownloadTask] = useState<WorkflowRunArchiveDownloadTaskResponse | null>(
null,
)
const archiveMonth = formatMonth(archive.year, archive.month)
const cachedTask = downloadTask ?? archive.download_task ?? null
const downloadTaskId = cachedTask?.download_id
const taskQuery = useQuery(
consoleQuery.workflowRunArchives.downloads.byDownloadId.get.queryOptions({
input: downloadTaskId
? {
params: {
download_id: downloadTaskId,
},
}
: skipToken,
enabled: !!downloadTaskId && isPreparingStatus(cachedTask?.status),
refetchInterval: (query) =>
isPreparingStatus(query.state.data?.status ?? cachedTask?.status)
? DOWNLOAD_TASK_POLLING_INTERVAL
: false,
}),
)
const createDownloadMutation = useMutation(
consoleQuery.workflowRunArchives.downloads.post.mutationOptions(),
)
const currentTask = taskQuery.data ?? cachedTask
const isPreparing = createDownloadMutation.isPending || isPreparingStatus(currentTask?.status)
const isReady = currentTask?.status === 'ready'
const isFailed = currentTask?.status === 'failed'
const failureReason = isFailed ? currentTask?.error?.trim() : undefined
const downloadHint = isReady
? t(($) => $['archives.downloadHint.ready'], { ns: 'appLog' })
: isFailed
? failureReason
? t(($) => $['archives.downloadHint.failedWithReason'], {
ns: 'appLog',
reason: failureReason,
})
: t(($) => $['archives.downloadHint.failed'], { ns: 'appLog' })
: isPreparing
? t(($) => $['archives.downloadHint.preparing'], { ns: 'appLog' })
: t(($) => $['archives.downloadHint.prepare'], { ns: 'appLog' })
const prepareDownload = () => {
if (createDownloadMutation.isPending) return
createDownloadMutation.mutate(
{
body: {
year: archive.year,
month: archive.month,
},
},
{
onSuccess: (task) => {
setDownloadTask(task)
toast.success(
task.status === 'ready'
? t(($) => $['archives.action.downloadReady'], { ns: 'appLog' })
: t(($) => $['archives.action.prepareStarted'], { ns: 'appLog' }),
)
},
onError: () => {
toast.error(t(($) => $['archives.action.prepareFailed'], { ns: 'appLog' }))
},
},
)
}
const downloadArchive = () => {
if (!currentTask || currentTask.status !== 'ready') return
globalThis.location.assign(buildArchiveDownloadFileUrl(currentTask.download_id))
}
const buttonContent = (() => {
if (isPreparing) return t(($) => $['archives.action.preparing'], { ns: 'appLog' })
if (isReady) return t(($) => $['operation.download'], { ns: 'common' })
if (isFailed) return t(($) => $['operation.retry'], { ns: 'common' })
return t(($) => $['archives.action.prepareDownload'], { ns: 'appLog' })
})()
const buttonAriaLabel = isReady
? t(($) => $['archives.action.downloadMonth'], { ns: 'appLog', month: archiveMonth })
: t(($) => $['archives.action.prepareMonth'], { ns: 'appLog', month: archiveMonth })
const buttonIconClassName = isReady ? 'i-ri-download-2-line' : 'i-ri-inbox-archive-line'
const onAction = isReady ? downloadArchive : prepareDownload
return (
<div
className={cn(
'grid min-h-15 items-center gap-3 border-b border-divider-subtle px-4 py-3 last:border-b-0',
tableGridClassName,
)}
>
<div className="min-w-0 text-center">
<span className="truncate system-sm-semibold text-text-primary">{archiveMonth}</span>
</div>
<div className="text-center system-sm-medium text-text-secondary tabular-nums">
{formatNumber(archive.workflow_run_count)}
</div>
<div className="text-center system-sm-medium text-text-secondary tabular-nums">
{formatBytes(archive.archive_bytes)}
</div>
<div className="flex min-w-0 justify-center">
<Tooltip>
<TooltipTrigger
render={
<Button
size="small"
variant="secondary"
loading={isPreparing}
disabled={isPreparing}
className="gap-1 px-2"
aria-label={buttonAriaLabel}
onClick={onAction}
>
{!isPreparing && (
<span className={cn(buttonIconClassName, 'size-3.5')} aria-hidden="true" />
)}
{buttonContent}
</Button>
}
/>
<TooltipContent
placement="top"
className={cn(
'max-w-[260px] text-center text-text-tertiary',
isFailed && 'max-w-[300px] text-start [overflow-wrap:anywhere] whitespace-pre-wrap',
)}
>
{downloadHint}
</TooltipContent>
</Tooltip>
</div>
</div>
)
}
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "تكرار",
"agentLogDetail.iterations": "التكرارات",
"agentLogDetail.toolUsed": "الأداة المستخدمة",
"archives.action.downloadMonth": "تنزيل أرشيف {{month}}",
"archives.action.downloadReady": "تنزيل الأرشيف جاهز.",
"archives.action.prepareDownload": "تحضير التنزيل",
"archives.action.prepareFailed": "فشل تحضير تنزيل الأرشيف.",
"archives.action.prepareMonth": "تحضير تنزيل أرشيف {{month}}",
"archives.action.prepareStarted": "جارٍ تحضير تنزيل الأرشيف.",
"archives.action.preparing": "جارٍ التحضير",
"archives.description": "نزّل صادرات CSV شهرية لسجلات تشغيل سير العمل المؤرشفة في مساحة العمل هذه.",
"archives.downloadHint.failed": "فشل تحضير هذا الأرشيف. أعد المحاولة لبدء مهمة تنزيل جديدة.",
"archives.downloadHint.failedWithReason": "فشل تحضير هذا الأرشيف: {{reason}}",
"archives.downloadHint.prepare": "يبدأ مهمة في الخلفية لتحضير تصدير CSV هذا. يمكنك مغادرة هذه الصفحة بعد بدئها.",
"archives.downloadHint.preparing": "يتم تحضير تصدير CSV في الخلفية. يمكنك مغادرة هذه الصفحة والعودة لاحقًا.",
"archives.downloadHint.ready": "تصدير CSV هذا جاهز للتنزيل.",
"archives.empty.description": "ستظهر سجلات تشغيل سير العمل المؤرشفة هنا بعد انتهاء مهمة الاحتفاظ.",
"archives.empty.title": "لا توجد سجلات مؤرشفة",
"archives.error.description": "حدّث الصفحة أو حاول مرة أخرى لاحقًا.",
"archives.error.title": "تعذّر تحميل السجلات المؤرشفة",
"archives.notice.action": "عرض السجلات المؤرشفة",
"archives.notice.description": "قد تكون بعض السجلات ضمن هذا النطاق الزمني قد أُرشفت.",
"archives.summary.latest": "أحدث أرشيف",
"archives.summary.months": "الأشهر المؤرشفة",
"archives.summary.runs": "تشغيلات سير العمل",
"archives.summary.size": "الحجم المخزّن",
"archives.table.action": "الإجراء",
"archives.table.month": "الشهر",
"archives.table.runs": "التشغيلات",
"archives.table.size": "الحجم",
"archives.title": "السجلات المؤرشفة",
"archives.upgradeTip.description": "رقِّ خطتك للحفاظ على سجلات تشغيل سير العمل من الحذف مستقبلًا. لا يمكن استعادة السجلات التي حُذفت بالفعل.",
"archives.upgradeTip.title": "رقِّ خطتك للاحتفاظ بسجلات سير العمل المستقبلية",
"dateTimeFormat": "MM/DD/YYYY hh:mm A",
"description": "تسجل السجلات حالة تشغيل التطبيق، بما في ذلك مدخلات المستخدم واستجابات الذكاء الاصطناعي.",
"detail.annotationTip": "تحسينات تم وضع علامة عليها بواسطة {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iteration",
"agentLogDetail.iterations": "Iterationen",
"agentLogDetail.toolUsed": "Verwendetes Werkzeug",
"archives.action.downloadMonth": "{{month}}-Archiv herunterladen",
"archives.action.downloadReady": "Der Archivdownload ist bereit.",
"archives.action.prepareDownload": "Download vorbereiten",
"archives.action.prepareFailed": "Archivdownload konnte nicht vorbereitet werden.",
"archives.action.prepareMonth": "{{month}}-Archivdownload vorbereiten",
"archives.action.prepareStarted": "Der Archivdownload wird vorbereitet.",
"archives.action.preparing": "Wird vorbereitet",
"archives.description": "Monatliche CSV-Exporte archivierter Workflow-Ausführungsprotokolle in diesem Workspace herunterladen.",
"archives.downloadHint.failed": "Die Vorbereitung dieses Archivs ist fehlgeschlagen. Versuchen Sie es erneut, um eine neue Downloadaufgabe zu starten.",
"archives.downloadHint.failedWithReason": "Die Vorbereitung dieses Archivs ist fehlgeschlagen: {{reason}}",
"archives.downloadHint.prepare": "Startet eine Hintergrundaufgabe zur Vorbereitung dieses CSV-Exports. Sie können diese Seite nach dem Start verlassen.",
"archives.downloadHint.preparing": "Der CSV-Export wird im Hintergrund vorbereitet. Sie können diese Seite verlassen und später zurückkehren.",
"archives.downloadHint.ready": "Dieser CSV-Export ist zum Herunterladen bereit.",
"archives.empty.description": "Archivierte Workflow-Ausführungsprotokolle werden hier angezeigt, nachdem der Aufbewahrungsjob abgeschlossen ist.",
"archives.empty.title": "Keine archivierten Protokolle",
"archives.error.description": "Aktualisieren Sie die Seite oder versuchen Sie es später erneut.",
"archives.error.title": "Archivierte Protokolle konnten nicht geladen werden",
"archives.notice.action": "Archivierte Protokolle anzeigen",
"archives.notice.description": "Einige Protokolle in diesem Zeitraum wurden möglicherweise archiviert.",
"archives.summary.latest": "Neueste Archivierung",
"archives.summary.months": "Archivierte Monate",
"archives.summary.runs": "Workflow-Ausführungen",
"archives.summary.size": "Gespeicherte Größe",
"archives.table.action": "Aktion",
"archives.table.month": "Monat",
"archives.table.runs": "Ausführungen",
"archives.table.size": "Größe",
"archives.title": "Archivierte Protokolle",
"archives.upgradeTip.description": "Führen Sie ein Upgrade Ihres Plans durch, damit Workflow-Ausführungsprotokolle künftig nicht gelöscht werden. Bereits gelöschte Protokolle können nicht wiederhergestellt werden.",
"archives.upgradeTip.title": "Upgrade durchführen, um zukünftige Workflow-Protokolle aufzubewahren",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "Die Protokolle zeichnen den Betriebsstatus der Anwendung auf, einschließlich Benutzereingaben und KI-Antworten.",
"detail.annotationTip": "Verbesserungen markiert von {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iteration",
"agentLogDetail.iterations": "Iterations",
"agentLogDetail.toolUsed": "Tool Used",
"archives.action.downloadMonth": "Download {{month}} archive",
"archives.action.downloadReady": "Archive download is ready.",
"archives.action.prepareDownload": "Prepare download",
"archives.action.prepareFailed": "Failed to prepare archive download.",
"archives.action.prepareMonth": "Prepare {{month}} archive download",
"archives.action.prepareStarted": "Archive download is being prepared.",
"archives.action.preparing": "Preparing",
"archives.description": "Download monthly CSV exports of archived workflow run logs across this workspace.",
"archives.downloadHint.failed": "Preparing this archive failed. Retry to start a new download task.",
"archives.downloadHint.failedWithReason": "Preparing this archive failed: {{reason}}",
"archives.downloadHint.prepare": "Starts a background task to prepare this CSV export. You can leave this page after it starts.",
"archives.downloadHint.preparing": "The CSV export is being prepared in the background. You can leave this page and come back later.",
"archives.downloadHint.ready": "This CSV export is ready to download.",
"archives.empty.description": "Archived workflow run logs will appear here after the retention job finishes.",
"archives.empty.title": "No archived logs",
"archives.error.description": "Refresh the page or try again later.",
"archives.error.title": "Could not load archived logs",
"archives.notice.action": "View archived logs",
"archives.notice.description": "Some logs in this time range may have been archived.",
"archives.summary.latest": "Latest archive",
"archives.summary.months": "Archived months",
"archives.summary.runs": "Workflow runs",
"archives.summary.size": "Stored size",
"archives.table.action": "Action",
"archives.table.month": "Month",
"archives.table.runs": "Runs",
"archives.table.size": "Size",
"archives.title": "Archived logs",
"archives.upgradeTip.description": "Upgrade your plan to keep workflow run logs from being deleted going forward. Logs that have already been deleted cannot be recovered.",
"archives.upgradeTip.title": "Upgrade to retain future workflow logs",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "The logs record the running status of the application, including user inputs and AI replies.",
"detail.annotationTip": "Improvements Marked by {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iteración",
"agentLogDetail.iterations": "Iteraciones",
"agentLogDetail.toolUsed": "Herramienta Utilizada",
"archives.action.downloadMonth": "Descargar archivo de {{month}}",
"archives.action.downloadReady": "La descarga del archivo está lista.",
"archives.action.prepareDownload": "Preparar descarga",
"archives.action.prepareFailed": "No se pudo preparar la descarga del archivo.",
"archives.action.prepareMonth": "Preparar la descarga del archivo de {{month}}",
"archives.action.prepareStarted": "Se está preparando la descarga del archivo.",
"archives.action.preparing": "Preparando",
"archives.description": "Descarga exportaciones CSV mensuales de los registros archivados de ejecuciones de workflow en este espacio de trabajo.",
"archives.downloadHint.failed": "No se pudo preparar este archivo. Vuelve a intentarlo para iniciar una nueva tarea de descarga.",
"archives.downloadHint.failedWithReason": "No se pudo preparar este archivo: {{reason}}",
"archives.downloadHint.prepare": "Inicia una tarea en segundo plano para preparar esta exportación CSV. Puedes salir de esta página después de iniciarla.",
"archives.downloadHint.preparing": "La exportación CSV se está preparando en segundo plano. Puedes salir de esta página y volver más tarde.",
"archives.downloadHint.ready": "Esta exportación CSV está lista para descargar.",
"archives.empty.description": "Los registros archivados de ejecuciones de workflow aparecerán aquí cuando finalice la tarea de retención.",
"archives.empty.title": "No hay registros archivados",
"archives.error.description": "Actualiza la página o inténtalo de nuevo más tarde.",
"archives.error.title": "No se pudieron cargar los registros archivados",
"archives.notice.action": "Ver registros archivados",
"archives.notice.description": "Es posible que algunos registros de este intervalo de tiempo se hayan archivado.",
"archives.summary.latest": "Último archivo",
"archives.summary.months": "Meses archivados",
"archives.summary.runs": "Ejecuciones de workflow",
"archives.summary.size": "Tamaño almacenado",
"archives.table.action": "Acción",
"archives.table.month": "Mes",
"archives.table.runs": "Ejecuciones",
"archives.table.size": "Tamaño",
"archives.title": "Registros archivados",
"archives.upgradeTip.description": "Mejora tu plan para evitar que los registros de ejecuciones de workflow se eliminen en adelante. Los registros que ya se hayan eliminado no se pueden recuperar.",
"archives.upgradeTip.title": "Mejora tu plan para conservar futuros registros de workflow",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "Los registros registran el estado de ejecución de la aplicación, incluyendo las entradas de usuario y las respuestas de la IA.",
"detail.annotationTip": "Mejoras Marcadas por {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "تکرار",
"agentLogDetail.iterations": "تکرارها",
"agentLogDetail.toolUsed": "ابزار استفاده شده",
"archives.action.downloadMonth": "دانلود بایگانی {{month}}",
"archives.action.downloadReady": "دانلود بایگانی آماده است.",
"archives.action.prepareDownload": "آماده‌سازی دانلود",
"archives.action.prepareFailed": "آماده‌سازی دانلود بایگانی ناموفق بود.",
"archives.action.prepareMonth": "آماده‌سازی دانلود بایگانی {{month}}",
"archives.action.prepareStarted": "دانلود بایگانی در حال آماده‌سازی است.",
"archives.action.preparing": "در حال آماده‌سازی",
"archives.description": "خروجی‌های CSV ماهانه از لاگ‌های بایگانی‌شده اجرای workflow در این فضای کاری را دانلود کنید.",
"archives.downloadHint.failed": "آماده‌سازی این بایگانی ناموفق بود. برای شروع یک وظیفه دانلود جدید دوباره تلاش کنید.",
"archives.downloadHint.failedWithReason": "آماده‌سازی این بایگانی ناموفق بود: {{reason}}",
"archives.downloadHint.prepare": "یک وظیفه پس‌زمینه برای آماده‌سازی این خروجی CSV شروع می‌کند. پس از شروع می‌توانید این صفحه را ترک کنید.",
"archives.downloadHint.preparing": "خروجی CSV در پس‌زمینه در حال آماده‌سازی است. می‌توانید این صفحه را ترک کنید و بعداً بازگردید.",
"archives.downloadHint.ready": "این خروجی CSV آماده دانلود است.",
"archives.empty.description": "لاگ‌های بایگانی‌شده اجرای workflow پس از پایان کار نگهداشت در اینجا نمایش داده می‌شوند.",
"archives.empty.title": "لاگ بایگانی‌شده‌ای وجود ندارد",
"archives.error.description": "صفحه را تازه‌سازی کنید یا بعداً دوباره تلاش کنید.",
"archives.error.title": "امکان بارگیری لاگ‌های بایگانی‌شده وجود ندارد",
"archives.notice.action": "مشاهده لاگ‌های بایگانی‌شده",
"archives.notice.description": "ممکن است برخی لاگ‌ها در این بازه زمانی بایگانی شده باشند.",
"archives.summary.latest": "آخرین بایگانی",
"archives.summary.months": "ماه‌های بایگانی‌شده",
"archives.summary.runs": "اجراهای workflow",
"archives.summary.size": "اندازه ذخیره‌شده",
"archives.table.action": "عملیات",
"archives.table.month": "ماه",
"archives.table.runs": "اجراها",
"archives.table.size": "اندازه",
"archives.title": "لاگ‌های بایگانی‌شده",
"archives.upgradeTip.description": "طرح خود را ارتقا دهید تا لاگ‌های اجرای workflow از این پس حذف نشوند. لاگ‌هایی که قبلاً حذف شده‌اند قابل بازیابی نیستند.",
"archives.upgradeTip.title": "برای نگهداری لاگ‌های آینده workflow ارتقا دهید",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "لاگ‌ها وضعیت اجرایی برنامه را ثبت می‌کنند، شامل ورودی‌های کاربر و پاسخ‌های هوش مصنوعی.",
"detail.annotationTip": "بهبودها توسط {{user}} علامت‌گذاری شده است",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Itération",
"agentLogDetail.iterations": "Itérations",
"agentLogDetail.toolUsed": "Outil utilisé",
"archives.action.downloadMonth": "Télécharger larchive de {{month}}",
"archives.action.downloadReady": "Le téléchargement de larchive est prêt.",
"archives.action.prepareDownload": "Préparer le téléchargement",
"archives.action.prepareFailed": "Impossible de préparer le téléchargement de larchive.",
"archives.action.prepareMonth": "Préparer le téléchargement de larchive de {{month}}",
"archives.action.prepareStarted": "Le téléchargement de larchive est en cours de préparation.",
"archives.action.preparing": "Préparation",
"archives.description": "Téléchargez des exports CSV mensuels des journaux archivés dexécutions de workflow dans cet espace de travail.",
"archives.downloadHint.failed": "La préparation de cette archive a échoué. Réessayez pour démarrer une nouvelle tâche de téléchargement.",
"archives.downloadHint.failedWithReason": "La préparation de cette archive a échoué : {{reason}}",
"archives.downloadHint.prepare": "Démarre une tâche en arrière-plan pour préparer cet export CSV. Vous pouvez quitter cette page après son démarrage.",
"archives.downloadHint.preparing": "Lexport CSV est en cours de préparation en arrière-plan. Vous pouvez quitter cette page et revenir plus tard.",
"archives.downloadHint.ready": "Cet export CSV est prêt à être téléchargé.",
"archives.empty.description": "Les journaux archivés dexécutions de workflow apparaîtront ici une fois la tâche de rétention terminée.",
"archives.empty.title": "Aucun journal archivé",
"archives.error.description": "Actualisez la page ou réessayez plus tard.",
"archives.error.title": "Impossible de charger les journaux archivés",
"archives.notice.action": "Voir les journaux archivés",
"archives.notice.description": "Certains journaux de cette période peuvent avoir été archivés.",
"archives.summary.latest": "Dernière archive",
"archives.summary.months": "Mois archivés",
"archives.summary.runs": "Exécutions de workflow",
"archives.summary.size": "Taille stockée",
"archives.table.action": "Action",
"archives.table.month": "Mois",
"archives.table.runs": "Exécutions",
"archives.table.size": "Taille",
"archives.title": "Journaux archivés",
"archives.upgradeTip.description": "Passez à une offre supérieure pour empêcher la suppression des journaux dexécutions de workflow à lavenir. Les journaux déjà supprimés ne peuvent pas être récupérés.",
"archives.upgradeTip.title": "Passez à une offre supérieure pour conserver les futurs journaux de workflow",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "Les journaux enregistrent l'état d'exécution de l'application, y compris les entrées utilisateur et les réponses de l'IA.",
"detail.annotationTip": "Améliorations marquées par {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "चलना",
"agentLogDetail.iterations": "पुनरूक्तियाँ",
"agentLogDetail.toolUsed": "प्रयुक्त उपकरण",
"archives.action.downloadMonth": "{{month}} का आर्काइव डाउनलोड करें",
"archives.action.downloadReady": "आर्काइव डाउनलोड तैयार है।",
"archives.action.prepareDownload": "डाउनलोड तैयार करें",
"archives.action.prepareFailed": "आर्काइव डाउनलोड तैयार करने में विफल रहा।",
"archives.action.prepareMonth": "{{month}} का आर्काइव डाउनलोड तैयार करें",
"archives.action.prepareStarted": "आर्काइव डाउनलोड तैयार किया जा रहा है।",
"archives.action.preparing": "तैयार किया जा रहा है",
"archives.description": "इस वर्कस्पेस में आर्काइव किए गए workflow रन लॉग के मासिक CSV निर्यात डाउनलोड करें।",
"archives.downloadHint.failed": "इस आर्काइव को तैयार करना विफल रहा। नया डाउनलोड कार्य शुरू करने के लिए फिर से प्रयास करें।",
"archives.downloadHint.failedWithReason": "इस आर्काइव को तैयार करना विफल रहा: {{reason}}",
"archives.downloadHint.prepare": "इस CSV निर्यात को तैयार करने के लिए बैकग्राउंड कार्य शुरू करता है। शुरू होने के बाद आप यह पेज छोड़ सकते हैं।",
"archives.downloadHint.preparing": "CSV निर्यात बैकग्राउंड में तैयार किया जा रहा है। आप यह पेज छोड़कर बाद में वापस आ सकते हैं।",
"archives.downloadHint.ready": "यह CSV निर्यात डाउनलोड के लिए तैयार है।",
"archives.empty.description": "रिटेंशन जॉब पूरी होने के बाद आर्काइव किए गए workflow रन लॉग यहां दिखाई देंगे।",
"archives.empty.title": "कोई आर्काइव लॉग नहीं",
"archives.error.description": "पेज रीफ्रेश करें या बाद में फिर से प्रयास करें।",
"archives.error.title": "आर्काइव लॉग लोड नहीं हो सके",
"archives.notice.action": "आर्काइव लॉग देखें",
"archives.notice.description": "इस समय सीमा के कुछ लॉग आर्काइव किए गए हो सकते हैं।",
"archives.summary.latest": "नवीनतम आर्काइव",
"archives.summary.months": "आर्काइव किए गए महीने",
"archives.summary.runs": "Workflow रन",
"archives.summary.size": "संग्रहीत आकार",
"archives.table.action": "कार्रवाई",
"archives.table.month": "महीना",
"archives.table.runs": "रन",
"archives.table.size": "आकार",
"archives.title": "आर्काइव लॉग",
"archives.upgradeTip.description": "आगे से workflow रन लॉग हटने से बचाने के लिए अपनी योजना अपग्रेड करें। पहले से हटाए गए लॉग पुनर्प्राप्त नहीं किए जा सकते।",
"archives.upgradeTip.title": "भविष्य के workflow लॉग सुरक्षित रखने के लिए अपग्रेड करें",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "लॉग्स एप्लिकेशन के रनिंग स्टेटस को रिकॉर्ड करते हैं, जिसमें यूजर इनपुट और एआई रिप्लाईज़ शामिल हैं।",
"detail.annotationTip": "{{user}} द्वारा सुधार चिह्नित",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iterasi",
"agentLogDetail.iterations": "Iterasi",
"agentLogDetail.toolUsed": "Alat yang Digunakan",
"archives.action.downloadMonth": "Unduh arsip {{month}}",
"archives.action.downloadReady": "Unduhan arsip sudah siap.",
"archives.action.prepareDownload": "Siapkan unduhan",
"archives.action.prepareFailed": "Gagal menyiapkan unduhan arsip.",
"archives.action.prepareMonth": "Siapkan unduhan arsip {{month}}",
"archives.action.prepareStarted": "Unduhan arsip sedang disiapkan.",
"archives.action.preparing": "Menyiapkan",
"archives.description": "Unduh ekspor CSV bulanan dari log workflow run yang diarsipkan di workspace ini.",
"archives.downloadHint.failed": "Gagal menyiapkan arsip ini. Coba lagi untuk memulai tugas unduhan baru.",
"archives.downloadHint.failedWithReason": "Gagal menyiapkan arsip ini: {{reason}}",
"archives.downloadHint.prepare": "Memulai tugas latar belakang untuk menyiapkan ekspor CSV ini. Anda dapat meninggalkan halaman ini setelah tugas dimulai.",
"archives.downloadHint.preparing": "Ekspor CSV sedang disiapkan di latar belakang. Anda dapat meninggalkan halaman ini dan kembali nanti.",
"archives.downloadHint.ready": "Ekspor CSV ini siap diunduh.",
"archives.empty.description": "Log workflow run yang diarsipkan akan muncul di sini setelah tugas retensi selesai.",
"archives.empty.title": "Tidak ada log yang diarsipkan",
"archives.error.description": "Muat ulang halaman atau coba lagi nanti.",
"archives.error.title": "Tidak dapat memuat log yang diarsipkan",
"archives.notice.action": "Lihat log yang diarsipkan",
"archives.notice.description": "Beberapa log dalam rentang waktu ini mungkin telah diarsipkan.",
"archives.summary.latest": "Arsip terbaru",
"archives.summary.months": "Bulan yang diarsipkan",
"archives.summary.runs": "Workflow run",
"archives.summary.size": "Ukuran tersimpan",
"archives.table.action": "Tindakan",
"archives.table.month": "Bulan",
"archives.table.runs": "Run",
"archives.table.size": "Ukuran",
"archives.title": "Log yang diarsipkan",
"archives.upgradeTip.description": "Tingkatkan paket Anda agar log workflow run tidak dihapus ke depannya. Log yang sudah dihapus tidak dapat dipulihkan.",
"archives.upgradeTip.title": "Tingkatkan paket untuk menyimpan log workflow mendatang",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "Log mencatat status berjalan aplikasi, termasuk input pengguna dan balasan AI.",
"detail.annotationTip": "Peningkatan Ditandai oleh {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iterazione",
"agentLogDetail.iterations": "Iterazioni",
"agentLogDetail.toolUsed": "Strumento Usato",
"archives.action.downloadMonth": "Scarica larchivio di {{month}}",
"archives.action.downloadReady": "Il download dellarchivio è pronto.",
"archives.action.prepareDownload": "Prepara download",
"archives.action.prepareFailed": "Impossibile preparare il download dellarchivio.",
"archives.action.prepareMonth": "Prepara il download dellarchivio di {{month}}",
"archives.action.prepareStarted": "Il download dellarchivio è in preparazione.",
"archives.action.preparing": "Preparazione",
"archives.description": "Scarica esportazioni CSV mensili dei log archiviati delle esecuzioni workflow in questo workspace.",
"archives.downloadHint.failed": "La preparazione di questo archivio non è riuscita. Riprova per avviare una nuova attività di download.",
"archives.downloadHint.failedWithReason": "La preparazione di questo archivio non è riuscita: {{reason}}",
"archives.downloadHint.prepare": "Avvia unattività in background per preparare questa esportazione CSV. Puoi lasciare questa pagina dopo lavvio.",
"archives.downloadHint.preparing": "Lesportazione CSV viene preparata in background. Puoi lasciare questa pagina e tornare più tardi.",
"archives.downloadHint.ready": "Questa esportazione CSV è pronta per il download.",
"archives.empty.description": "I log archiviati delle esecuzioni workflow appariranno qui al termine del job di retention.",
"archives.empty.title": "Nessun log archiviato",
"archives.error.description": "Aggiorna la pagina o riprova più tardi.",
"archives.error.title": "Impossibile caricare i log archiviati",
"archives.notice.action": "Visualizza log archiviati",
"archives.notice.description": "Alcuni log in questo intervallo di tempo potrebbero essere stati archiviati.",
"archives.summary.latest": "Archivio più recente",
"archives.summary.months": "Mesi archiviati",
"archives.summary.runs": "Esecuzioni workflow",
"archives.summary.size": "Dimensione archiviata",
"archives.table.action": "Azione",
"archives.table.month": "Mese",
"archives.table.runs": "Esecuzioni",
"archives.table.size": "Dimensione",
"archives.title": "Log archiviati",
"archives.upgradeTip.description": "Aggiorna il piano per evitare che i log delle esecuzioni workflow vengano eliminati in futuro. I log già eliminati non possono essere recuperati.",
"archives.upgradeTip.title": "Aggiorna per conservare i log workflow futuri",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "I registri registrano lo stato di esecuzione dell'applicazione, inclusi input degli utenti e risposte AI.",
"detail.annotationTip": "Miglioramenti Segnalati da {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "反復",
"agentLogDetail.iterations": "反復",
"agentLogDetail.toolUsed": "使用したツール",
"archives.action.downloadMonth": "{{month}} のアーカイブをダウンロード",
"archives.action.downloadReady": "アーカイブのダウンロード準備が完了しました。",
"archives.action.prepareDownload": "ダウンロードを準備",
"archives.action.prepareFailed": "アーカイブのダウンロード準備に失敗しました。",
"archives.action.prepareMonth": "{{month}} のアーカイブダウンロードを準備",
"archives.action.prepareStarted": "アーカイブのダウンロードを準備しています。",
"archives.action.preparing": "準備中",
"archives.description": "このワークスペース内のアーカイブされたワークフロー実行ログを月別 CSV エクスポートとしてダウンロードします。",
"archives.downloadHint.failed": "このアーカイブの準備に失敗しました。再試行すると新しいダウンロードタスクを開始します。",
"archives.downloadHint.failedWithReason": "このアーカイブの準備に失敗しました: {{reason}}",
"archives.downloadHint.prepare": "この CSV エクスポートを準備するバックグラウンドタスクを開始します。開始後はこのページを離れてもかまいません。",
"archives.downloadHint.preparing": "CSV エクスポートをバックグラウンドで準備しています。このページを離れて後で戻ることができます。",
"archives.downloadHint.ready": "この CSV エクスポートはダウンロードできます。",
"archives.empty.description": "保持ジョブが完了すると、アーカイブされたワークフロー実行ログがここに表示されます。",
"archives.empty.title": "アーカイブされたログはありません",
"archives.error.description": "ページを更新するか、後でもう一度お試しください。",
"archives.error.title": "アーカイブされたログを読み込めませんでした",
"archives.notice.action": "アーカイブされたログを表示",
"archives.notice.description": "この期間の一部のログはアーカイブされている可能性があります。",
"archives.summary.latest": "最新のアーカイブ",
"archives.summary.months": "アーカイブ済みの月",
"archives.summary.runs": "ワークフロー実行",
"archives.summary.size": "保存サイズ",
"archives.table.action": "操作",
"archives.table.month": "月",
"archives.table.runs": "実行",
"archives.table.size": "サイズ",
"archives.title": "アーカイブされたログ",
"archives.upgradeTip.description": "プランをアップグレードすると、今後のワークフロー実行ログが削除されないようになります。すでに削除されたログは復元できません。",
"archives.upgradeTip.title": "今後のワークフローログを保持するにはアップグレードしてください",
"dateTimeFormat": "YYYY/MM/DD hh:mm:ss A",
"description": "ログは、アプリケーションの実行状態を記録します。ユーザーの入力や AI の応答などが含まれます。",
"detail.annotationTip": "{{user}} によってマークされた改善",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "반복",
"agentLogDetail.iterations": "반복",
"agentLogDetail.toolUsed": "사용된 도구",
"archives.action.downloadMonth": "{{month}} 아카이브 다운로드",
"archives.action.downloadReady": "아카이브 다운로드가 준비되었습니다.",
"archives.action.prepareDownload": "다운로드 준비",
"archives.action.prepareFailed": "아카이브 다운로드를 준비하지 못했습니다.",
"archives.action.prepareMonth": "{{month}} 아카이브 다운로드 준비",
"archives.action.prepareStarted": "아카이브 다운로드를 준비하는 중입니다.",
"archives.action.preparing": "준비 중",
"archives.description": "이 워크스페이스의 보관된 워크플로 실행 로그를 월별 CSV 내보내기로 다운로드합니다.",
"archives.downloadHint.failed": "이 아카이브를 준비하지 못했습니다. 새 다운로드 작업을 시작하려면 다시 시도하세요.",
"archives.downloadHint.failedWithReason": "이 아카이브를 준비하지 못했습니다: {{reason}}",
"archives.downloadHint.prepare": "이 CSV 내보내기를 준비하는 백그라운드 작업을 시작합니다. 시작 후에는 이 페이지를 떠나도 됩니다.",
"archives.downloadHint.preparing": "CSV 내보내기가 백그라운드에서 준비 중입니다. 이 페이지를 떠났다가 나중에 다시 돌아올 수 있습니다.",
"archives.downloadHint.ready": "이 CSV 내보내기를 다운로드할 수 있습니다.",
"archives.empty.description": "보존 작업이 완료되면 보관된 워크플로 실행 로그가 여기에 표시됩니다.",
"archives.empty.title": "보관된 로그가 없습니다",
"archives.error.description": "페이지를 새로 고치거나 나중에 다시 시도하세요.",
"archives.error.title": "보관된 로그를 불러올 수 없습니다",
"archives.notice.action": "보관된 로그 보기",
"archives.notice.description": "이 시간 범위의 일부 로그가 보관되었을 수 있습니다.",
"archives.summary.latest": "최신 아카이브",
"archives.summary.months": "보관된 월",
"archives.summary.runs": "워크플로 실행",
"archives.summary.size": "저장된 크기",
"archives.table.action": "작업",
"archives.table.month": "월",
"archives.table.runs": "실행",
"archives.table.size": "크기",
"archives.title": "보관된 로그",
"archives.upgradeTip.description": "플랜을 업그레이드하면 앞으로 워크플로 실행 로그가 삭제되지 않도록 보관할 수 있습니다. 이미 삭제된 로그는 복구할 수 없습니다.",
"archives.upgradeTip.title": "향후 워크플로 로그를 보관하려면 업그레이드하세요",
"dateTimeFormat": "YYYY/MM/DD HH:mm:ss",
"description": "로그는 애플리케이션 실행 상태를 기록합니다. 사용자 입력 및 AI 응답이 포함됩니다.",
"detail.annotationTip": "{{user}}에 의해 향상됨",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iteration",
"agentLogDetail.iterations": "Iterations",
"agentLogDetail.toolUsed": "Tool Used",
"archives.action.downloadMonth": "Archief van {{month}} downloaden",
"archives.action.downloadReady": "De archiefdownload is klaar.",
"archives.action.prepareDownload": "Download voorbereiden",
"archives.action.prepareFailed": "Kan archiefdownload niet voorbereiden.",
"archives.action.prepareMonth": "Archiefdownload van {{month}} voorbereiden",
"archives.action.prepareStarted": "De archiefdownload wordt voorbereid.",
"archives.action.preparing": "Voorbereiden",
"archives.description": "Download maandelijkse CSV-exports van gearchiveerde workflow-runlogs in deze werkruimte.",
"archives.downloadHint.failed": "Het voorbereiden van dit archief is mislukt. Probeer opnieuw om een nieuwe downloadtaak te starten.",
"archives.downloadHint.failedWithReason": "Het voorbereiden van dit archief is mislukt: {{reason}}",
"archives.downloadHint.prepare": "Start een achtergrondtaak om deze CSV-export voor te bereiden. U kunt deze pagina verlaten nadat de taak is gestart.",
"archives.downloadHint.preparing": "De CSV-export wordt op de achtergrond voorbereid. U kunt deze pagina verlaten en later terugkomen.",
"archives.downloadHint.ready": "Deze CSV-export is klaar om te downloaden.",
"archives.empty.description": "Gearchiveerde workflow-runlogs verschijnen hier nadat de bewaartaak is voltooid.",
"archives.empty.title": "Geen gearchiveerde logs",
"archives.error.description": "Vernieuw de pagina of probeer het later opnieuw.",
"archives.error.title": "Kan gearchiveerde logs niet laden",
"archives.notice.action": "Gearchiveerde logs bekijken",
"archives.notice.description": "Sommige logs in deze periode zijn mogelijk gearchiveerd.",
"archives.summary.latest": "Nieuwste archief",
"archives.summary.months": "Gearchiveerde maanden",
"archives.summary.runs": "Workflow-runs",
"archives.summary.size": "Opgeslagen grootte",
"archives.table.action": "Actie",
"archives.table.month": "Maand",
"archives.table.runs": "Runs",
"archives.table.size": "Grootte",
"archives.title": "Gearchiveerde logs",
"archives.upgradeTip.description": "Upgrade uw abonnement om workflow-runlogs voortaan te bewaren en niet te laten verwijderen. Logs die al zijn verwijderd, kunnen niet worden hersteld.",
"archives.upgradeTip.title": "Upgrade om toekomstige workflowlogs te bewaren",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "The logs record the running status of the application, including user inputs and AI replies.",
"detail.annotationTip": "Improvements Marked by {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iteracja",
"agentLogDetail.iterations": "Iteracje",
"agentLogDetail.toolUsed": "Użyte narzędzia",
"archives.action.downloadMonth": "Pobierz archiwum z {{month}}",
"archives.action.downloadReady": "Pobieranie archiwum jest gotowe.",
"archives.action.prepareDownload": "Przygotuj pobieranie",
"archives.action.prepareFailed": "Nie udało się przygotować pobierania archiwum.",
"archives.action.prepareMonth": "Przygotuj pobieranie archiwum z {{month}}",
"archives.action.prepareStarted": "Pobieranie archiwum jest przygotowywane.",
"archives.action.preparing": "Przygotowywanie",
"archives.description": "Pobierz miesięczne eksporty CSV zarchiwizowanych logów uruchomień workflow w tym obszarze roboczym.",
"archives.downloadHint.failed": "Przygotowanie tego archiwum nie powiodło się. Spróbuj ponownie, aby uruchomić nowe zadanie pobierania.",
"archives.downloadHint.failedWithReason": "Przygotowanie tego archiwum nie powiodło się: {{reason}}",
"archives.downloadHint.prepare": "Uruchamia zadanie w tle przygotowujące ten eksport CSV. Po jego rozpoczęciu możesz opuścić tę stronę.",
"archives.downloadHint.preparing": "Eksport CSV jest przygotowywany w tle. Możesz opuścić tę stronę i wrócić później.",
"archives.downloadHint.ready": "Ten eksport CSV jest gotowy do pobrania.",
"archives.empty.description": "Zarchiwizowane logi uruchomień workflow pojawią się tutaj po zakończeniu zadania retencji.",
"archives.empty.title": "Brak zarchiwizowanych logów",
"archives.error.description": "Odśwież stronę lub spróbuj ponownie później.",
"archives.error.title": "Nie można załadować zarchiwizowanych logów",
"archives.notice.action": "Wyświetl zarchiwizowane logi",
"archives.notice.description": "Niektóre logi z tego zakresu czasu mogły zostać zarchiwizowane.",
"archives.summary.latest": "Najnowsze archiwum",
"archives.summary.months": "Zarchiwizowane miesiące",
"archives.summary.runs": "Uruchomienia workflow",
"archives.summary.size": "Zapisany rozmiar",
"archives.table.action": "Akcja",
"archives.table.month": "Miesiąc",
"archives.table.runs": "Uruchomienia",
"archives.table.size": "Rozmiar",
"archives.title": "Zarchiwizowane logi",
"archives.upgradeTip.description": "Uaktualnij plan, aby logi uruchomień workflow nie były usuwane w przyszłości. Logów, które zostały już usunięte, nie można odzyskać.",
"archives.upgradeTip.title": "Uaktualnij, aby zachowywać przyszłe logi workflow",
"dateTimeFormat": "DD/MM/YYYY HH:mm:ss",
"description": "Dzienniki rejestrują stan działania aplikacji, w tym dane wejściowe użytkowników i odpowiedzi AI.",
"detail.annotationTip": "Usprawnienia oznaczone przez {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iteração",
"agentLogDetail.iterations": "Iterações",
"agentLogDetail.toolUsed": "Ferramenta usada",
"archives.action.downloadMonth": "Baixar arquivo de {{month}}",
"archives.action.downloadReady": "O download do arquivo está pronto.",
"archives.action.prepareDownload": "Preparar download",
"archives.action.prepareFailed": "Falha ao preparar o download do arquivo.",
"archives.action.prepareMonth": "Preparar download do arquivo de {{month}}",
"archives.action.prepareStarted": "O download do arquivo está sendo preparado.",
"archives.action.preparing": "Preparando",
"archives.description": "Baixe exportações CSV mensais dos logs arquivados de execuções de workflow neste workspace.",
"archives.downloadHint.failed": "Falha ao preparar este arquivo. Tente novamente para iniciar uma nova tarefa de download.",
"archives.downloadHint.failedWithReason": "Falha ao preparar este arquivo: {{reason}}",
"archives.downloadHint.prepare": "Inicia uma tarefa em segundo plano para preparar esta exportação CSV. Você pode sair desta página depois que ela começar.",
"archives.downloadHint.preparing": "A exportação CSV está sendo preparada em segundo plano. Você pode sair desta página e voltar mais tarde.",
"archives.downloadHint.ready": "Esta exportação CSV está pronta para download.",
"archives.empty.description": "Os logs arquivados de execuções de workflow aparecerão aqui depois que a tarefa de retenção terminar.",
"archives.empty.title": "Nenhum log arquivado",
"archives.error.description": "Atualize a página ou tente novamente mais tarde.",
"archives.error.title": "Não foi possível carregar os logs arquivados",
"archives.notice.action": "Ver logs arquivados",
"archives.notice.description": "Alguns logs neste intervalo de tempo podem ter sido arquivados.",
"archives.summary.latest": "Arquivo mais recente",
"archives.summary.months": "Meses arquivados",
"archives.summary.runs": "Execuções de workflow",
"archives.summary.size": "Tamanho armazenado",
"archives.table.action": "Ação",
"archives.table.month": "Mês",
"archives.table.runs": "Execuções",
"archives.table.size": "Tamanho",
"archives.title": "Logs arquivados",
"archives.upgradeTip.description": "Faça upgrade do seu plano para evitar que logs de execuções de workflow sejam excluídos daqui em diante. Logs que já foram excluídos não podem ser recuperados.",
"archives.upgradeTip.title": "Faça upgrade para manter logs futuros de workflow",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "Os registros registram o status de execução do aplicativo, incluindo entradas do usuário e respostas do AI.",
"detail.annotationTip": "Melhorias Marcadas por {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iterație",
"agentLogDetail.iterations": "Iterații",
"agentLogDetail.toolUsed": "Instrument utilizat",
"archives.action.downloadMonth": "Descarcă arhiva {{month}}",
"archives.action.downloadReady": "Descărcarea arhivei este gata.",
"archives.action.prepareDownload": "Pregătește descărcarea",
"archives.action.prepareFailed": "Pregătirea descărcării arhivei a eșuat.",
"archives.action.prepareMonth": "Pregătește descărcarea arhivei {{month}}",
"archives.action.prepareStarted": "Descărcarea arhivei este în pregătire.",
"archives.action.preparing": "Se pregătește",
"archives.description": "Descarcă exporturi CSV lunare ale jurnalelor arhivate de rulări workflow din acest spațiu de lucru.",
"archives.downloadHint.failed": "Pregătirea acestei arhive a eșuat. Reîncearcă pentru a porni o nouă sarcină de descărcare.",
"archives.downloadHint.failedWithReason": "Pregătirea acestei arhive a eșuat: {{reason}}",
"archives.downloadHint.prepare": "Pornește o sarcină în fundal pentru pregătirea acestui export CSV. Poți părăsi pagina după ce pornește.",
"archives.downloadHint.preparing": "Exportul CSV este pregătit în fundal. Poți părăsi pagina și reveni mai târziu.",
"archives.downloadHint.ready": "Acest export CSV este gata de descărcare.",
"archives.empty.description": "Jurnalele arhivate de rulări workflow vor apărea aici după finalizarea sarcinii de retenție.",
"archives.empty.title": "Nu există jurnale arhivate",
"archives.error.description": "Reîmprospătează pagina sau încearcă din nou mai târziu.",
"archives.error.title": "Jurnalele arhivate nu au putut fi încărcate",
"archives.notice.action": "Vezi jurnalele arhivate",
"archives.notice.description": "Este posibil ca unele jurnale din acest interval de timp să fi fost arhivate.",
"archives.summary.latest": "Cea mai recentă arhivă",
"archives.summary.months": "Luni arhivate",
"archives.summary.runs": "Rulări workflow",
"archives.summary.size": "Dimensiune stocată",
"archives.table.action": "Acțiune",
"archives.table.month": "Lună",
"archives.table.runs": "Rulări",
"archives.table.size": "Dimensiune",
"archives.title": "Jurnale arhivate",
"archives.upgradeTip.description": "Fă upgrade planului pentru ca jurnalele rulărilor workflow să nu mai fie șterse pe viitor. Jurnalele care au fost deja șterse nu pot fi recuperate.",
"archives.upgradeTip.title": "Fă upgrade pentru a păstra viitoarele jurnale workflow",
"dateTimeFormat": "DD/MM/YYYY hh:mm:ss A",
"description": "Jurnalele înregistrează starea de funcționare a aplicației, inclusiv intrările utilizatorilor și răspunsurile AI.",
"detail.annotationTip": "Îmbunătățiri marcate de {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Итерация",
"agentLogDetail.iterations": "Итерации",
"agentLogDetail.toolUsed": "Использованный инструмент",
"archives.action.downloadMonth": "Скачать архив за {{month}}",
"archives.action.downloadReady": "Архив готов к скачиванию.",
"archives.action.prepareDownload": "Подготовить скачивание",
"archives.action.prepareFailed": "Не удалось подготовить скачивание архива.",
"archives.action.prepareMonth": "Подготовить скачивание архива за {{month}}",
"archives.action.prepareStarted": "Скачивание архива подготавливается.",
"archives.action.preparing": "Подготовка",
"archives.description": "Скачивайте ежемесячные CSV-экспорты архивных логов запусков workflow в этом рабочем пространстве.",
"archives.downloadHint.failed": "Не удалось подготовить этот архив. Повторите попытку, чтобы запустить новую задачу скачивания.",
"archives.downloadHint.failedWithReason": "Не удалось подготовить этот архив: {{reason}}",
"archives.downloadHint.prepare": "Запускает фоновую задачу для подготовки этого CSV-экспорта. После запуска можно покинуть эту страницу.",
"archives.downloadHint.preparing": "CSV-экспорт подготавливается в фоновом режиме. Можно покинуть эту страницу и вернуться позже.",
"archives.downloadHint.ready": "Этот CSV-экспорт готов к скачиванию.",
"archives.empty.description": "Архивные логи запусков workflow появятся здесь после завершения задания хранения.",
"archives.empty.title": "Нет архивных логов",
"archives.error.description": "Обновите страницу или повторите попытку позже.",
"archives.error.title": "Не удалось загрузить архивные логи",
"archives.notice.action": "Посмотреть архивные логи",
"archives.notice.description": "Некоторые логи в этом временном диапазоне могли быть заархивированы.",
"archives.summary.latest": "Последний архив",
"archives.summary.months": "Архивные месяцы",
"archives.summary.runs": "Запуски workflow",
"archives.summary.size": "Размер в хранилище",
"archives.table.action": "Действие",
"archives.table.month": "Месяц",
"archives.table.runs": "Запуски",
"archives.table.size": "Размер",
"archives.title": "Архивные логи",
"archives.upgradeTip.description": "Обновите тариф, чтобы в дальнейшем логи запусков workflow не удалялись. Логи, которые уже были удалены, восстановить нельзя.",
"archives.upgradeTip.title": "Обновите тариф, чтобы сохранять будущие логи workflow",
"dateTimeFormat": "DD.MM.YYYY HH:mm:ss",
"description": "В логах записывается состояние работы приложения, включая пользовательский ввод и ответы ИИ.",
"detail.annotationTip": "Улучшения, отмеченные {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Iteracija",
"agentLogDetail.iterations": "Iteracije",
"agentLogDetail.toolUsed": "Uporabljeno orodje",
"archives.action.downloadMonth": "Prenesi arhiv za {{month}}",
"archives.action.downloadReady": "Prenos arhiva je pripravljen.",
"archives.action.prepareDownload": "Pripravi prenos",
"archives.action.prepareFailed": "Priprava prenosa arhiva ni uspela.",
"archives.action.prepareMonth": "Pripravi prenos arhiva za {{month}}",
"archives.action.prepareStarted": "Prenos arhiva se pripravlja.",
"archives.action.preparing": "Pripravljanje",
"archives.description": "Prenesite mesečne izvoze CSV arhiviranih dnevnikov zagonov workflow v tem delovnem prostoru.",
"archives.downloadHint.failed": "Priprava tega arhiva ni uspela. Poskusite znova, da zaženete novo opravilo prenosa.",
"archives.downloadHint.failedWithReason": "Priprava tega arhiva ni uspela: {{reason}}",
"archives.downloadHint.prepare": "Zažene opravilo v ozadju za pripravo tega izvoza CSV. Po začetku lahko zapustite to stran.",
"archives.downloadHint.preparing": "Izvoz CSV se pripravlja v ozadju. To stran lahko zapustite in se vrnete pozneje.",
"archives.downloadHint.ready": "Ta izvoz CSV je pripravljen za prenos.",
"archives.empty.description": "Arhivirani dnevniki zagonov workflow bodo prikazani tukaj po zaključku opravila hrambe.",
"archives.empty.title": "Ni arhiviranih dnevnikov",
"archives.error.description": "Osvežite stran ali poskusite znova pozneje.",
"archives.error.title": "Arhiviranih dnevnikov ni bilo mogoče naložiti",
"archives.notice.action": "Prikaži arhivirane dnevnike",
"archives.notice.description": "Nekateri dnevniki v tem časovnem obdobju so bili morda arhivirani.",
"archives.summary.latest": "Najnovejši arhiv",
"archives.summary.months": "Arhivirani meseci",
"archives.summary.runs": "Zagoni workflow",
"archives.summary.size": "Shranjena velikost",
"archives.table.action": "Dejanje",
"archives.table.month": "Mesec",
"archives.table.runs": "Zagoni",
"archives.table.size": "Velikost",
"archives.title": "Arhivirani dnevniki",
"archives.upgradeTip.description": "Nadgradite paket, da se dnevniki zagonov workflow v prihodnje ne bodo brisali. Dnevnikov, ki so že izbrisani, ni mogoče obnoviti.",
"archives.upgradeTip.title": "Nadgradite za hranjenje prihodnjih dnevnikov workflow",
"dateTimeFormat": "DD.MM.YYYY hh:mm:ss A",
"description": "Dnevniki beležijo stanje delovanja aplikacije, vključno z vnosi uporabnikov in odgovori umetne inteligence.",
"detail.annotationTip": "Izboljšave, ki jih je označil {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "เกิด ซ้ำ",
"agentLogDetail.iterations": "เกิด ซ้ำ",
"agentLogDetail.toolUsed": "เครื่องมือที่ใช้",
"archives.action.downloadMonth": "ดาวน์โหลดไฟล์เก็บถาวรของ {{month}}",
"archives.action.downloadReady": "ไฟล์เก็บถาวรพร้อมดาวน์โหลดแล้ว",
"archives.action.prepareDownload": "เตรียมดาวน์โหลด",
"archives.action.prepareFailed": "เตรียมดาวน์โหลดไฟล์เก็บถาวรไม่สำเร็จ",
"archives.action.prepareMonth": "เตรียมดาวน์โหลดไฟล์เก็บถาวรของ {{month}}",
"archives.action.prepareStarted": "กำลังเตรียมดาวน์โหลดไฟล์เก็บถาวร",
"archives.action.preparing": "กำลังเตรียม",
"archives.description": "ดาวน์โหลดไฟล์ส่งออก CSV รายเดือนของบันทึกการรัน workflow ที่เก็บถาวรในเวิร์กสเปซนี้",
"archives.downloadHint.failed": "เตรียมไฟล์เก็บถาวรนี้ไม่สำเร็จ ลองอีกครั้งเพื่อเริ่มงานดาวน์โหลดใหม่",
"archives.downloadHint.failedWithReason": "เตรียมไฟล์เก็บถาวรนี้ไม่สำเร็จ: {{reason}}",
"archives.downloadHint.prepare": "เริ่มงานเบื้องหลังเพื่อเตรียมไฟล์ส่งออก CSV นี้ คุณสามารถออกจากหน้านี้ได้หลังจากเริ่มงานแล้ว",
"archives.downloadHint.preparing": "กำลังเตรียมไฟล์ส่งออก CSV ในเบื้องหลัง คุณสามารถออกจากหน้านี้และกลับมาใหม่ภายหลังได้",
"archives.downloadHint.ready": "ไฟล์ส่งออก CSV นี้พร้อมดาวน์โหลดแล้ว",
"archives.empty.description": "บันทึกการรัน workflow ที่เก็บถาวรจะแสดงที่นี่หลังจากงานเก็บรักษาเสร็จสิ้น",
"archives.empty.title": "ไม่มีบันทึกที่เก็บถาวร",
"archives.error.description": "รีเฟรชหน้า หรือลองอีกครั้งในภายหลัง",
"archives.error.title": "ไม่สามารถโหลดบันทึกที่เก็บถาวรได้",
"archives.notice.action": "ดูบันทึกที่เก็บถาวร",
"archives.notice.description": "บันทึกบางส่วนในช่วงเวลานี้อาจถูกเก็บถาวรแล้ว",
"archives.summary.latest": "ไฟล์เก็บถาวรล่าสุด",
"archives.summary.months": "เดือนที่เก็บถาวร",
"archives.summary.runs": "การรัน workflow",
"archives.summary.size": "ขนาดที่จัดเก็บ",
"archives.table.action": "การดำเนินการ",
"archives.table.month": "เดือน",
"archives.table.runs": "การรัน",
"archives.table.size": "ขนาด",
"archives.title": "บันทึกที่เก็บถาวร",
"archives.upgradeTip.description": "อัปเกรดแพ็กเกจของคุณเพื่อป้องกันไม่ให้บันทึกการรัน workflow ถูกลบในอนาคต บันทึกที่ถูกลบไปแล้วไม่สามารถกู้คืนได้",
"archives.upgradeTip.title": "อัปเกรดเพื่อเก็บบันทึก workflow ในอนาคต",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "บันทึกบันทึกสถานะการทํางานของแอปพลิเคชัน รวมถึงการป้อนข้อมูลของผู้ใช้และการตอบกลับ AI",
"detail.annotationTip": "การปรับปรุงที่ทําเครื่องหมายโดย {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Yineleme",
"agentLogDetail.iterations": "Yinelemeler",
"agentLogDetail.toolUsed": "Kullanılan Araç",
"archives.action.downloadMonth": "{{month}} arşivini indir",
"archives.action.downloadReady": "Arşiv indirmesi hazır.",
"archives.action.prepareDownload": "İndirmeyi hazırla",
"archives.action.prepareFailed": "Arşiv indirmesi hazırlanamadı.",
"archives.action.prepareMonth": "{{month}} arşiv indirmesini hazırla",
"archives.action.prepareStarted": "Arşiv indirmesi hazırlanıyor.",
"archives.action.preparing": "Hazırlanıyor",
"archives.description": "Bu çalışma alanındaki arşivlenmiş workflow çalıştırma günlüklerinin aylık CSV dışa aktarımlarını indirin.",
"archives.downloadHint.failed": "Bu arşiv hazırlanamadı. Yeni bir indirme görevi başlatmak için tekrar deneyin.",
"archives.downloadHint.failedWithReason": "Bu arşiv hazırlanamadı: {{reason}}",
"archives.downloadHint.prepare": "Bu CSV dışa aktarımını hazırlamak için arka planda bir görev başlatır. Başladıktan sonra bu sayfadan ayrılabilirsiniz.",
"archives.downloadHint.preparing": "CSV dışa aktarımı arka planda hazırlanıyor. Bu sayfadan ayrılıp daha sonra geri dönebilirsiniz.",
"archives.downloadHint.ready": "Bu CSV dışa aktarımı indirilmeye hazır.",
"archives.empty.description": "Arşivlenmiş workflow çalıştırma günlükleri, saklama işi tamamlandıktan sonra burada görünür.",
"archives.empty.title": "Arşivlenmiş günlük yok",
"archives.error.description": "Sayfayı yenileyin veya daha sonra tekrar deneyin.",
"archives.error.title": "Arşivlenmiş günlükler yüklenemedi",
"archives.notice.action": "Arşivlenmiş günlükleri görüntüle",
"archives.notice.description": "Bu zaman aralığındaki bazı günlükler arşivlenmiş olabilir.",
"archives.summary.latest": "En son arşiv",
"archives.summary.months": "Arşivlenen aylar",
"archives.summary.runs": "Workflow çalıştırmaları",
"archives.summary.size": "Depolanan boyut",
"archives.table.action": "Eylem",
"archives.table.month": "Ay",
"archives.table.runs": "Çalıştırmalar",
"archives.table.size": "Boyut",
"archives.title": "Arşivlenmiş günlükler",
"archives.upgradeTip.description": "Workflow çalıştırma günlüklerinin bundan sonra silinmesini önlemek için planınızı yükseltin. Zaten silinmiş günlükler kurtarılamaz.",
"archives.upgradeTip.title": "Gelecekteki workflow günlüklerini saklamak için yükseltin",
"dateTimeFormat": "GG/AA/YYYY ss:dd ÖÖ/ÖS",
"description": "Günlükler, kullanıcının girdileri ve AI tepkileri dahil olmak üzere uygulamanın çalışma durumunu kaydeder.",
"detail.annotationTip": "{{user}} tarafından işaretlenen iyileştirmeler",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Ітерація",
"agentLogDetail.iterations": "Ітерації",
"agentLogDetail.toolUsed": "Використаний інструмент",
"archives.action.downloadMonth": "Завантажити архів за {{month}}",
"archives.action.downloadReady": "Архів готовий до завантаження.",
"archives.action.prepareDownload": "Підготувати завантаження",
"archives.action.prepareFailed": "Не вдалося підготувати завантаження архіву.",
"archives.action.prepareMonth": "Підготувати завантаження архіву за {{month}}",
"archives.action.prepareStarted": "Завантаження архіву готується.",
"archives.action.preparing": "Підготовка",
"archives.description": "Завантажуйте щомісячні CSV-експорти архівних логів запусків workflow у цьому робочому просторі.",
"archives.downloadHint.failed": "Не вдалося підготувати цей архів. Спробуйте ще раз, щоб запустити нове завдання завантаження.",
"archives.downloadHint.failedWithReason": "Не вдалося підготувати цей архів: {{reason}}",
"archives.downloadHint.prepare": "Запускає фонове завдання для підготовки цього CSV-експорту. Після запуску можна залишити цю сторінку.",
"archives.downloadHint.preparing": "CSV-експорт готується у фоновому режимі. Можна залишити цю сторінку й повернутися пізніше.",
"archives.downloadHint.ready": "Цей CSV-експорт готовий до завантаження.",
"archives.empty.description": "Архівні логи запусків workflow з’являться тут після завершення завдання зберігання.",
"archives.empty.title": "Немає архівних логів",
"archives.error.description": "Оновіть сторінку або спробуйте ще раз пізніше.",
"archives.error.title": "Не вдалося завантажити архівні логи",
"archives.notice.action": "Переглянути архівні логи",
"archives.notice.description": "Деякі логи в цьому часовому діапазоні могли бути заархівовані.",
"archives.summary.latest": "Останній архів",
"archives.summary.months": "Архівні місяці",
"archives.summary.runs": "Запуски workflow",
"archives.summary.size": "Розмір у сховищі",
"archives.table.action": "Дія",
"archives.table.month": "Місяць",
"archives.table.runs": "Запуски",
"archives.table.size": "Розмір",
"archives.title": "Архівні логи",
"archives.upgradeTip.description": "Оновіть тариф, щоб надалі логи запусків workflow не видалялися. Логи, які вже було видалено, неможливо відновити.",
"archives.upgradeTip.title": "Оновіть тариф, щоб зберігати майбутні логи workflow",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "Журнали фіксують робочий статус додатка, включаючи введення користувачів та відповіді штучного інтелекту.",
"detail.annotationTip": "Покращення Позначені Користувачем {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "Lần lặp",
"agentLogDetail.iterations": "Số lần lặp",
"agentLogDetail.toolUsed": "Công cụ đã sử dụng",
"archives.action.downloadMonth": "Tải xuống bản lưu trữ {{month}}",
"archives.action.downloadReady": "Bản lưu trữ đã sẵn sàng để tải xuống.",
"archives.action.prepareDownload": "Chuẩn bị tải xuống",
"archives.action.prepareFailed": "Không thể chuẩn bị bản lưu trữ để tải xuống.",
"archives.action.prepareMonth": "Chuẩn bị tải xuống bản lưu trữ {{month}}",
"archives.action.prepareStarted": "Đang chuẩn bị bản lưu trữ để tải xuống.",
"archives.action.preparing": "Đang chuẩn bị",
"archives.description": "Tải xuống các bản xuất CSV hằng tháng của log workflow run đã lưu trữ trong workspace này.",
"archives.downloadHint.failed": "Không thể chuẩn bị bản lưu trữ này. Thử lại để bắt đầu một tác vụ tải xuống mới.",
"archives.downloadHint.failedWithReason": "Không thể chuẩn bị bản lưu trữ này: {{reason}}",
"archives.downloadHint.prepare": "Bắt đầu một tác vụ nền để chuẩn bị bản xuất CSV này. Bạn có thể rời trang này sau khi tác vụ bắt đầu.",
"archives.downloadHint.preparing": "Bản xuất CSV đang được chuẩn bị trong nền. Bạn có thể rời trang này và quay lại sau.",
"archives.downloadHint.ready": "Bản xuất CSV này đã sẵn sàng để tải xuống.",
"archives.empty.description": "Log workflow run đã lưu trữ sẽ xuất hiện ở đây sau khi tác vụ lưu giữ hoàn tất.",
"archives.empty.title": "Không có log đã lưu trữ",
"archives.error.description": "Làm mới trang hoặc thử lại sau.",
"archives.error.title": "Không thể tải log đã lưu trữ",
"archives.notice.action": "Xem log đã lưu trữ",
"archives.notice.description": "Một số log trong khoảng thời gian này có thể đã được lưu trữ.",
"archives.summary.latest": "Bản lưu trữ mới nhất",
"archives.summary.months": "Tháng đã lưu trữ",
"archives.summary.runs": "Workflow run",
"archives.summary.size": "Dung lượng đã lưu",
"archives.table.action": "Hành động",
"archives.table.month": "Tháng",
"archives.table.runs": "Lượt chạy",
"archives.table.size": "Dung lượng",
"archives.title": "Log đã lưu trữ",
"archives.upgradeTip.description": "Nâng cấp gói của bạn để log workflow run không bị xóa trong tương lai. Không thể khôi phục các log đã bị xóa.",
"archives.upgradeTip.title": "Nâng cấp để lưu giữ log workflow trong tương lai",
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"description": "Nhật ký ghi lại trạng thái hoạt động của ứng dụng, bao gồm đầu vào của người dùng và phản hồi của trí tuệ nhân tạo.",
"detail.annotationTip": "Cải thiện được đánh dấu bởi {{user}}",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "迭代",
"agentLogDetail.iterations": "迭代次数",
"agentLogDetail.toolUsed": "使用工具",
"archives.action.downloadMonth": "下载 {{month}} 归档",
"archives.action.downloadReady": "归档下载已准备好。",
"archives.action.prepareDownload": "准备下载",
"archives.action.prepareFailed": "归档下载准备失败。",
"archives.action.prepareMonth": "准备下载 {{month}} 归档",
"archives.action.prepareStarted": "正在准备归档下载。",
"archives.action.preparing": "准备中",
"archives.description": "下载此工作空间中已归档工作流运行日志的月度 CSV 导出。",
"archives.downloadHint.failed": "此归档准备失败。重试将启动新的下载任务。",
"archives.downloadHint.failedWithReason": "此归档准备失败:{{reason}}",
"archives.downloadHint.prepare": "启动后台任务来准备此 CSV 导出。任务开始后你可以离开此页面。",
"archives.downloadHint.preparing": "CSV 导出正在后台准备中。你可以离开此页面,稍后再回来。",
"archives.downloadHint.ready": "此 CSV 导出已可下载。",
"archives.empty.description": "保留任务完成后,已归档的工作流运行日志会显示在这里。",
"archives.empty.title": "暂无归档日志",
"archives.error.description": "请刷新页面或稍后重试。",
"archives.error.title": "无法加载归档日志",
"archives.notice.action": "查看归档日志",
"archives.notice.description": "此时间范围内的部分日志可能已被归档。",
"archives.summary.latest": "最新归档",
"archives.summary.months": "归档月份",
"archives.summary.runs": "工作流运行",
"archives.summary.size": "存储大小",
"archives.table.action": "操作",
"archives.table.month": "月份",
"archives.table.runs": "运行次数",
"archives.table.size": "大小",
"archives.title": "归档日志",
"archives.upgradeTip.description": "升级计划后,未来的工作流运行日志将不会被删除。已经删除的日志无法恢复。",
"archives.upgradeTip.title": "升级以保留未来的工作流日志",
"dateTimeFormat": "YYYY-MM-DD HH:mm:ss",
"description": "日志记录了应用的运行情况,包括用户的输入和 AI 的回复。",
"detail.annotationTip": "{{user}} 标记的改进回复",
+30
View File
@@ -4,6 +4,36 @@
"agentLogDetail.iteration": "迭代",
"agentLogDetail.iterations": "迭代次數",
"agentLogDetail.toolUsed": "使用工具",
"archives.action.downloadMonth": "下載 {{month}} 封存",
"archives.action.downloadReady": "封存下載已準備好。",
"archives.action.prepareDownload": "準備下載",
"archives.action.prepareFailed": "封存下載準備失敗。",
"archives.action.prepareMonth": "準備下載 {{month}} 封存",
"archives.action.prepareStarted": "正在準備封存下載。",
"archives.action.preparing": "準備中",
"archives.description": "下載此工作區中已封存工作流程執行記錄的每月 CSV 匯出。",
"archives.downloadHint.failed": "此封存準備失敗。重試將啟動新的下載任務。",
"archives.downloadHint.failedWithReason": "此封存準備失敗:{{reason}}",
"archives.downloadHint.prepare": "啟動背景任務來準備此 CSV 匯出。任務開始後你可以離開此頁面。",
"archives.downloadHint.preparing": "CSV 匯出正在背景準備中。你可以離開此頁面,稍後再回來。",
"archives.downloadHint.ready": "此 CSV 匯出已可下載。",
"archives.empty.description": "保留任務完成後,已封存的工作流程執行記錄會顯示在這裡。",
"archives.empty.title": "沒有封存記錄",
"archives.error.description": "請重新整理頁面或稍後再試。",
"archives.error.title": "無法載入封存記錄",
"archives.notice.action": "查看封存記錄",
"archives.notice.description": "此時間範圍內的部分記錄可能已被封存。",
"archives.summary.latest": "最新封存",
"archives.summary.months": "封存月份",
"archives.summary.runs": "工作流程執行",
"archives.summary.size": "儲存大小",
"archives.table.action": "操作",
"archives.table.month": "月份",
"archives.table.runs": "執行次數",
"archives.table.size": "大小",
"archives.title": "封存記錄",
"archives.upgradeTip.description": "升級方案後,未來的工作流程執行記錄將不會被刪除。已刪除的記錄無法復原。",
"archives.upgradeTip.title": "升級以保留未來的工作流程記錄",
"dateTimeFormat": "YYYY-MM-DD HH:mm:ss",
"description": "日誌記錄了應用的執行情況,包括使用者的輸入和 AI 的回覆。",
"detail.annotationTip": "{{user}} 標記的改進回覆",