Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c7d1f4cae | ||
|
|
85b3d62445 | ||
|
|
44cda898a4 | ||
|
|
35a495d91d | ||
|
|
2f99652203 | ||
|
|
dc1131b6df | ||
|
|
c6a50b14ec | ||
|
|
3ee9d66aaa | ||
|
|
9b117b0207 | ||
|
|
f8fff54682 | ||
|
|
89bc288a4b | ||
|
|
819ecad3f7 | ||
|
|
65d2b4c1c2 | ||
|
|
c911d42b31 | ||
|
|
bca16261ea | ||
|
|
f002fa8a98 | ||
|
|
43c9fb96ee | ||
|
|
a5a7c762a3 | ||
|
|
063e390c5d | ||
|
|
79c60ddfe2 | ||
|
|
2b0996236f | ||
|
|
c192a3c16e | ||
|
|
3b3c25273a | ||
|
|
5bed2666bb | ||
|
|
1f6e39b0ad | ||
|
|
5d0da5a27e | ||
|
|
055beabfec | ||
|
|
fd06801caa | ||
|
|
37c5f78a7f | ||
|
|
093d47cf05 | ||
|
|
dac5be487d | ||
|
|
12d1998fe4 |
@@ -1,6 +1,10 @@
|
||||
name: Web Full-Stack E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feat/agent-v2-e2e-test
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
run-external-runtime:
|
||||
@@ -59,7 +63,7 @@ jobs:
|
||||
run: vp run e2e:full
|
||||
|
||||
- name: Run external runtime E2E tests
|
||||
if: ${{ inputs.run-external-runtime }}
|
||||
if: ${{ inputs.run-external-runtime || github.ref == 'refs/heads/feat/agent-v2-e2e-test' }}
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: [email protected]
|
||||
|
||||
@@ -663,6 +663,9 @@ PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
|
||||
# Marketplace configuration
|
||||
MARKETPLACE_ENABLED=true
|
||||
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||
|
||||
@@ -26,7 +26,6 @@ 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,
|
||||
@@ -55,7 +54,6 @@ __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",
|
||||
|
||||
@@ -56,15 +56,6 @@ 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(
|
||||
prefix: str,
|
||||
*,
|
||||
@@ -648,82 +639,6 @@ 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"
|
||||
|
||||
@@ -25,11 +25,10 @@ class AgentBackendConfig(BaseSettings):
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. "
|
||||
"Requires the agent backend to be wired with a shellctl entrypoint; keep it "
|
||||
"off until shellctl is deployed, otherwise every agent run that includes the "
|
||||
"shell layer will fail."
|
||||
"Requires the agent backend to be wired with a shellctl entrypoint before "
|
||||
"shell-using Agent runs are executed."
|
||||
),
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
|
||||
AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS: NonNegativeFloat = Field(
|
||||
|
||||
@@ -363,7 +363,10 @@ class FileAccessConfig(BaseSettings):
|
||||
INTERNAL_FILES_URL: str = Field(
|
||||
description="Internal base URL for file access within Docker network,"
|
||||
" used for plugin daemon and internal service communication."
|
||||
" Falls back to FILES_URL if not specified.",
|
||||
" Explicit INTERNAL_FILES_URL takes precedence; otherwise SERVER_CONSOLE_API_URL is used,"
|
||||
" then FILES_URL.",
|
||||
validation_alias=AliasChoices("INTERNAL_FILES_URL", "SERVER_CONSOLE_API_URL"),
|
||||
alias_priority=1,
|
||||
default="",
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ from . import (
|
||||
setup,
|
||||
spec,
|
||||
version,
|
||||
workflow_run_archive,
|
||||
)
|
||||
from .agent import composer as agent_composer
|
||||
from .agent import roster as agent_roster
|
||||
@@ -239,7 +238,6 @@ __all__ = [
|
||||
"workflow_draft_variable",
|
||||
"workflow_node_output_inspector",
|
||||
"workflow_run",
|
||||
"workflow_run_archive",
|
||||
"workflow_statistic",
|
||||
"workflow_trigger",
|
||||
"workspace",
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
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,
|
||||
is_admin_or_owner_required,
|
||||
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
|
||||
@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
|
||||
@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
|
||||
@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
|
||||
@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)
|
||||
@@ -1,5 +1,3 @@
|
||||
from mimetypes import guess_extension
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from flask_restx.api import HTTPStatus
|
||||
@@ -8,7 +6,7 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
import services
|
||||
from core.tools.signature import verify_plugin_file_signature
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from fields.file_fields import FileResponse
|
||||
|
||||
@@ -110,7 +108,7 @@ class PluginUploadFileApi(Resource):
|
||||
conversation_id=args.conversation_id,
|
||||
)
|
||||
|
||||
extension = guess_extension(tool_file.mimetype) or ".bin"
|
||||
extension = resolve_extension(filename=tool_file.name, mimetype=tool_file.mimetype)
|
||||
preview_url = ToolFileManager.sign_file(tool_file_id=tool_file.id, extension=extension)
|
||||
|
||||
# Create a dictionary with all the necessary attributes
|
||||
|
||||
@@ -476,6 +476,7 @@ class PluginDownloadFileRequestApi(Resource):
|
||||
user_from=payload.user_from,
|
||||
invoke_from=payload.invoke_from,
|
||||
file_mapping=payload.file.model_dump(mode="python", exclude_none=True),
|
||||
for_external=payload.for_external,
|
||||
)
|
||||
return BaseBackwardsInvocationResponse(
|
||||
data={
|
||||
|
||||
@@ -27,6 +27,7 @@ from clients.agent_backend import (
|
||||
AgentBackendInternalEventType,
|
||||
AgentBackendRunClient,
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunFailedInternalEvent,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
AgentBackendStreamInternalEvent,
|
||||
extract_runtime_layer_specs,
|
||||
@@ -57,6 +58,14 @@ from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_re
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage
|
||||
from graphon.model_runtime.errors.invoke import (
|
||||
InvokeAuthorizationError,
|
||||
InvokeBadRequestError,
|
||||
InvokeConnectionError,
|
||||
InvokeError,
|
||||
InvokeRateLimitError,
|
||||
InvokeServerUnavailableError,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import MessageAgentThought
|
||||
@@ -71,6 +80,22 @@ class _DefaultSessionScopeSnapshotId:
|
||||
|
||||
_DEFAULT_SESSION_SCOPE_SNAPSHOT_ID = _DefaultSessionScopeSnapshotId()
|
||||
|
||||
_AGENT_BACKEND_INVOKE_ERROR_BY_REASON: Mapping[str, type[InvokeError]] = {
|
||||
"InvokeAuthorizationError": InvokeAuthorizationError,
|
||||
"InvokeBadRequestError": InvokeBadRequestError,
|
||||
"CredentialsValidateFailedError": InvokeBadRequestError,
|
||||
"InvokeConnectionError": InvokeConnectionError,
|
||||
"InvokeRateLimitError": InvokeRateLimitError,
|
||||
"InvokeServerUnavailableError": InvokeServerUnavailableError,
|
||||
}
|
||||
|
||||
|
||||
def _agent_backend_failure_to_exception(event: AgentBackendRunFailedInternalEvent) -> Exception:
|
||||
err_cls = _AGENT_BACKEND_INVOKE_ERROR_BY_REASON.get(event.reason or "")
|
||||
if err_cls is not None:
|
||||
return err_cls(event.error)
|
||||
return AgentBackendError(event.error or "Agent backend run did not complete successfully.")
|
||||
|
||||
|
||||
def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]:
|
||||
if not user_query:
|
||||
@@ -412,12 +437,15 @@ class _AgentProcessRecorder:
|
||||
def _lookup_tool_thought(self, *, index: int, tool_call_id: str | None) -> str | None:
|
||||
if tool_call_id and tool_call_id in self._tool_by_call_id:
|
||||
return self._tool_by_call_id[tool_call_id]
|
||||
if index < 0:
|
||||
return None
|
||||
return self._tool_by_index.get(index)
|
||||
|
||||
def _remember_tool_thought(
|
||||
self, *, index: int, tool_call_id: str | None, tool_name: str | None, thought_id: str
|
||||
) -> None:
|
||||
self._tool_by_index[index] = thought_id
|
||||
if index >= 0:
|
||||
self._tool_by_index[index] = thought_id
|
||||
if tool_call_id:
|
||||
self._tool_by_call_id[tool_call_id] = thought_id
|
||||
if tool_name:
|
||||
@@ -433,6 +461,10 @@ class _AgentProcessRecorder:
|
||||
return None
|
||||
|
||||
def _mark_tool_observed(self, thought_id: str) -> None:
|
||||
self._tool_by_index = {index: value for index, value in self._tool_by_index.items() if value != thought_id}
|
||||
self._tool_by_call_id = {
|
||||
tool_call_id: value for tool_call_id, value in self._tool_by_call_id.items() if value != thought_id
|
||||
}
|
||||
for open_thought_ids in self._open_tool_by_name.values():
|
||||
open_thought_ids.discard(thought_id)
|
||||
|
||||
@@ -530,7 +562,12 @@ def _event_index(data: dict[str, Any]) -> int:
|
||||
|
||||
|
||||
def _string_or_none(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized or normalized.lower() in {"none", "null"}:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _json_or_text(value: Any) -> str | None:
|
||||
@@ -652,8 +689,9 @@ class AgentAppRunner:
|
||||
return
|
||||
|
||||
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
|
||||
error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully."
|
||||
raise AgentBackendError(str(error))
|
||||
if isinstance(terminal, AgentBackendRunFailedInternalEvent):
|
||||
raise _agent_backend_failure_to_exception(terminal)
|
||||
raise AgentBackendError("Agent backend run did not complete successfully.")
|
||||
|
||||
answer = self._terminal_output_to_answer(terminal.output)
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import JsonValue
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError, InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -127,6 +127,7 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
|
||||
},
|
||||
ModelCurrentlyNotSupportError: {"code": "model_currently_not_support", "status": 400},
|
||||
InvokeError: {"code": "completion_request_error", "status": 400},
|
||||
InvokeRateLimitError: {"code": "rate_limit_error", "status": 429},
|
||||
}
|
||||
|
||||
# Determine the response based on the type of exception
|
||||
|
||||
@@ -420,6 +420,7 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
|
||||
message.total_price = usage.total_price
|
||||
message.currency = usage.currency
|
||||
self._task_state.llm_result.usage.latency = message.provider_response_latency
|
||||
self._task_state.metadata.usage = self._task_state.llm_result.usage
|
||||
message.message_metadata = self._task_state.metadata.model_dump_json()
|
||||
|
||||
if trace_manager:
|
||||
|
||||
@@ -276,6 +276,7 @@ class RequestRequestDownloadFile(BaseModel):
|
||||
"validation",
|
||||
]
|
||||
file: RequestDownloadFileMapping
|
||||
for_external: bool = True
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import hmac
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections.abc import Generator
|
||||
from mimetypes import guess_extension, guess_type
|
||||
from uuid import uuid4
|
||||
@@ -26,7 +27,7 @@ logger = logging.getLogger(__name__)
|
||||
class ToolFileManager:
|
||||
@staticmethod
|
||||
def _build_graph_file_reference(tool_file: ToolFile) -> File:
|
||||
extension = guess_extension(tool_file.mimetype) or ".bin"
|
||||
extension = resolve_extension(filename=tool_file.name, mimetype=tool_file.mimetype)
|
||||
return File(
|
||||
file_type=get_file_type_by_mime_type(tool_file.mimetype),
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
@@ -70,7 +71,7 @@ class ToolFileManager:
|
||||
mimetype: str,
|
||||
filename: str | None = None,
|
||||
) -> ToolFile:
|
||||
extension = guess_extension(mimetype) or ".bin"
|
||||
extension = resolve_extension(filename=filename, mimetype=mimetype)
|
||||
unique_name = uuid4().hex
|
||||
unique_filename = f"{unique_name}{extension}"
|
||||
# default just as before
|
||||
@@ -120,7 +121,8 @@ class ToolFileManager:
|
||||
or response.headers.get("Content-Type", "").split(";")[0].strip()
|
||||
or "application/octet-stream"
|
||||
)
|
||||
extension = guess_extension(mimetype) or ".bin"
|
||||
url_filename = os.path.basename(urllib.parse.urlparse(file_url).path)
|
||||
extension = resolve_extension(filename=url_filename, mimetype=mimetype)
|
||||
unique_name = uuid4().hex
|
||||
filename = f"{unique_name}{extension}"
|
||||
filepath = f"tools/{tenant_id}/{filename}"
|
||||
@@ -220,4 +222,11 @@ def _factory() -> ToolFileManager:
|
||||
return ToolFileManager()
|
||||
|
||||
|
||||
def resolve_extension(*, filename: str | None, mimetype: str) -> str:
|
||||
filename_extension = os.path.splitext(filename or "")[1].lower()
|
||||
if filename_extension:
|
||||
return filename_extension
|
||||
return guess_extension(mimetype) or ".bin"
|
||||
|
||||
|
||||
set_tool_file_manager_factory(_factory)
|
||||
|
||||
@@ -3,7 +3,6 @@ import re
|
||||
from collections.abc import Generator
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from mimetypes import guess_extension
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -11,7 +10,7 @@ import numpy as np
|
||||
import pytz
|
||||
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
|
||||
from core.workflow.file_reference import parse_file_reference
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from libs.login import current_user
|
||||
@@ -91,7 +90,8 @@ class ToolFileMessageTransformer:
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
url = f"/files/tools/{tool_file.id}{guess_extension(tool_file.mimetype) or '.png'}"
|
||||
extension = resolve_extension(filename=tool_file.name, mimetype=tool_file.mimetype)
|
||||
url = cls.get_tool_file_url(tool_file_id=tool_file.id, extension=extension)
|
||||
meta = cls._with_tool_file_meta(
|
||||
message.meta,
|
||||
tool_file_id=str(tool_file.id),
|
||||
@@ -136,7 +136,8 @@ class ToolFileMessageTransformer:
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
url = cls.get_tool_file_url(tool_file_id=tool_file.id, extension=guess_extension(tool_file.mimetype))
|
||||
extension = resolve_extension(filename=tool_file.name, mimetype=tool_file.mimetype)
|
||||
url = cls.get_tool_file_url(tool_file_id=tool_file.id, extension=extension)
|
||||
meta = cls._with_tool_file_meta(meta, tool_file_id=str(tool_file.id))
|
||||
|
||||
# check if file is image
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, override
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentMessageDeltaInternalEvent,
|
||||
AgentBackendDeferredToolCallInternalEvent,
|
||||
AgentBackendError,
|
||||
AgentBackendHTTPError,
|
||||
@@ -481,6 +482,10 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
if isinstance(internal_event, AgentBackendStreamInternalEvent):
|
||||
self._record_stream_metadata(metadata, internal_event)
|
||||
continue
|
||||
if internal_event.type == AgentBackendInternalEventType.AGENT_MESSAGE_DELTA:
|
||||
if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent):
|
||||
self._record_agent_message_delta_metadata(metadata, internal_event)
|
||||
continue
|
||||
metadata["agent_backend"] = {
|
||||
**dict(metadata.get("agent_backend") or {}),
|
||||
"stream_event_count": stream_event_count,
|
||||
@@ -734,6 +739,17 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
agent_backend["usage"] = dict(usage)
|
||||
metadata["agent_backend"] = agent_backend
|
||||
|
||||
@staticmethod
|
||||
def _record_agent_message_delta_metadata(
|
||||
metadata: dict[str, Any], event: AgentBackendAgentMessageDeltaInternalEvent
|
||||
) -> None:
|
||||
agent_backend = dict(metadata.get("agent_backend") or {})
|
||||
agent_backend["agent_message_delta_count"] = int(agent_backend.get("agent_message_delta_count") or 0) + 1
|
||||
agent_backend["agent_message_delta_length"] = int(agent_backend.get("agent_message_delta_length") or 0) + len(
|
||||
event.delta
|
||||
)
|
||||
metadata["agent_backend"] = agent_backend
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
|
||||
@@ -10,13 +10,13 @@ trustworthy metadata.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mimetypes import guess_extension
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import DataError, SQLAlchemyError
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from core.tools.tool_file_manager import resolve_extension
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from graphon.file import File, FileTransferMethod, get_file_type_by_mime_type
|
||||
from models.tools import ToolFile
|
||||
@@ -46,7 +46,7 @@ def reback_tool_file_output(*, tenant_id: str, tool_file_id: str) -> File | None
|
||||
return None
|
||||
|
||||
mime_type = tool_file.mimetype or ""
|
||||
extension = guess_extension(mime_type) or ".bin"
|
||||
extension = resolve_extension(filename=tool_file.name, mimetype=mime_type)
|
||||
return File(
|
||||
type=get_file_type_by_mime_type(mime_type),
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
|
||||
@@ -156,7 +156,6 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"tasks.generate_summary_index_task", # summary index generation
|
||||
"tasks.regenerate_summary_index_task", # summary index regeneration
|
||||
"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
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -78,7 +77,6 @@ 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,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Literal, NotRequired, TypedDict, assert_never, cast
|
||||
@@ -285,7 +286,7 @@ def _build_from_remote_url(
|
||||
raise ValueError("Invalid file url")
|
||||
|
||||
mime_type, filename, file_size = get_remote_file_info(url)
|
||||
extension = mimetypes.guess_extension(mime_type) or ("." + filename.split(".")[-1] if "." in filename else ".bin")
|
||||
extension = os.path.splitext(filename)[1].lower() or mimetypes.guess_extension(mime_type) or ".bin"
|
||||
detected_file_type = standardize_file_type(extension=extension, mime_type=mime_type)
|
||||
file_type = _resolve_file_type(
|
||||
detected_file_type=detected_file_type,
|
||||
@@ -326,7 +327,12 @@ def _build_from_tool_file(
|
||||
if tool_file is None:
|
||||
raise ValueError(f"ToolFile {tool_file_id} not found")
|
||||
|
||||
extension = "." + tool_file.file_key.split(".")[-1] if "." in tool_file.file_key else ".bin"
|
||||
extension = (
|
||||
os.path.splitext(tool_file.name)[1].lower()
|
||||
or mimetypes.guess_extension(tool_file.mimetype)
|
||||
or os.path.splitext(tool_file.file_key)[1].lower()
|
||||
or ".bin"
|
||||
)
|
||||
detected_file_type = standardize_file_type(extension=extension, mime_type=tool_file.mimetype)
|
||||
file_type = _resolve_file_type(
|
||||
detected_file_type=detected_file_type,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
|
||||
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
|
||||
from fields.base import ResponseModel
|
||||
@@ -55,10 +56,19 @@ class MessageListItem(ResponseModel):
|
||||
created_at: int | None = None
|
||||
agent_thoughts: list[AgentThought]
|
||||
message_files: list[MessageFile]
|
||||
message_tokens: int = 0
|
||||
answer_tokens: int = 0
|
||||
provider_response_latency: float = 0
|
||||
total_price: Decimal | None = None
|
||||
currency: str | None = None
|
||||
status: str
|
||||
error: str | None = None
|
||||
extra_contents: list[ExecutionExtraContentDomainModel]
|
||||
|
||||
@computed_field
|
||||
def total_tokens(self) -> int:
|
||||
return self.message_tokens + self.answer_tokens
|
||||
|
||||
@field_validator("inputs", mode="before")
|
||||
@classmethod
|
||||
def _normalize_inputs(cls, value: JSONValueType) -> JSONValueType:
|
||||
|
||||
@@ -11,7 +11,6 @@ import hashlib
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
import boto3
|
||||
import orjson
|
||||
@@ -198,22 +197,13 @@ 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,
|
||||
*,
|
||||
filename: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> str:
|
||||
def generate_presigned_url(self, key: str, expires_in: int = 3600) -> 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.
|
||||
@@ -221,15 +211,10 @@ 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=params,
|
||||
Params={"Bucket": self.bucket, "Key": key},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
except ClientError as e:
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
"""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")
|
||||
@@ -144,7 +144,6 @@ from .workflow import (
|
||||
WorkflowNodeExecutionTriggeredFrom,
|
||||
WorkflowPause,
|
||||
WorkflowRun,
|
||||
WorkflowRunArchiveBundle,
|
||||
WorkflowType,
|
||||
resolve_workflow_kind,
|
||||
)
|
||||
@@ -283,7 +282,6 @@ __all__ = [
|
||||
"WorkflowNodeExecutionTriggeredFrom",
|
||||
"WorkflowPause",
|
||||
"WorkflowRun",
|
||||
"WorkflowRunArchiveBundle",
|
||||
"WorkflowRunTriggeredFrom",
|
||||
"WorkflowSchedulePlan",
|
||||
"WorkflowToolProvider",
|
||||
|
||||
@@ -1446,40 +1446,6 @@ 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"
|
||||
|
||||
|
||||
@@ -9690,61 +9690,6 @@ Suggest example workflow-generator instructions for the tenant
|
||||
| 200 | Suggestions generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)<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**
|
||||
|
||||
@@ -17669,19 +17614,25 @@ Built-in tool icons are URL strings; API-based tool icons are provider-defined p
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes |
|
||||
| answer | string | | Yes |
|
||||
| answer_tokens | integer | | No |
|
||||
| conversation_id | string | | Yes |
|
||||
| created_at | integer | | No |
|
||||
| currency | string | | No |
|
||||
| error | string | | No |
|
||||
| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes |
|
||||
| feedback | [SimpleFeedback](#simplefeedback) | | No |
|
||||
| id | string | | Yes |
|
||||
| inputs | object | | Yes |
|
||||
| message_files | [ [MessageFile](#messagefile) ] | | Yes |
|
||||
| message_tokens | integer | | No |
|
||||
| metadata | [JSONValueType](#jsonvaluetype) | | No |
|
||||
| parent_message_id | string | | No |
|
||||
| provider_response_latency | number | | No |
|
||||
| query | string | | Yes |
|
||||
| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes |
|
||||
| status | string | | Yes |
|
||||
| total_price | string | | No |
|
||||
| total_tokens | integer | | Yes |
|
||||
|
||||
#### ExternalApiTemplateListQuery
|
||||
|
||||
@@ -23349,71 +23300,6 @@ 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 |
|
||||
|
||||
@@ -3467,18 +3467,24 @@ Model class for i18n object.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes |
|
||||
| answer | string | | Yes |
|
||||
| answer_tokens | integer | | No |
|
||||
| conversation_id | string | | Yes |
|
||||
| created_at | integer | | No |
|
||||
| currency | string | | No |
|
||||
| error | string | | No |
|
||||
| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes |
|
||||
| feedback | [SimpleFeedback](#simplefeedback) | | No |
|
||||
| id | string | | Yes |
|
||||
| inputs | object | | Yes |
|
||||
| message_files | [ [MessageFile](#messagefile) ] | | Yes |
|
||||
| message_tokens | integer | | No |
|
||||
| parent_message_id | string | | No |
|
||||
| provider_response_latency | number | | No |
|
||||
| query | string | | Yes |
|
||||
| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes |
|
||||
| status | string | | Yes |
|
||||
| total_price | string | | No |
|
||||
| total_tokens | integer | | Yes |
|
||||
|
||||
#### MessageListQuery
|
||||
|
||||
|
||||
@@ -1685,19 +1685,25 @@ in form definiton, or a variable while the workflow is running.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes |
|
||||
| answer | string | | Yes |
|
||||
| answer_tokens | integer | | No |
|
||||
| conversation_id | string | | Yes |
|
||||
| created_at | integer | | No |
|
||||
| currency | string | | No |
|
||||
| error | string | | No |
|
||||
| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes |
|
||||
| feedback | [SimpleFeedback](#simplefeedback) | | No |
|
||||
| id | string | | Yes |
|
||||
| inputs | object | | Yes |
|
||||
| message_files | [ [MessageFile](#messagefile) ] | | Yes |
|
||||
| message_tokens | integer | | No |
|
||||
| metadata | [JSONValueType](#jsonvaluetype) | | No |
|
||||
| parent_message_id | string | | No |
|
||||
| provider_response_latency | number | | No |
|
||||
| query | string | | Yes |
|
||||
| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes |
|
||||
| status | string | | Yes |
|
||||
| total_price | string | | No |
|
||||
| total_tokens | integer | | Yes |
|
||||
|
||||
#### WebModelConfigResponse
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
@@ -259,14 +260,16 @@ def test_get_project_url_success(trace_instance: AliyunDataTrace):
|
||||
assert trace_instance.get_project_url() == "project-url"
|
||||
|
||||
|
||||
def test_get_project_url_error(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_get_project_url_error(
|
||||
trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
monkeypatch.setattr(trace_instance.trace_client, "get_project_url", MagicMock(side_effect=Exception("boom")))
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(aliyun_trace_module, "logger", logger_mock)
|
||||
|
||||
caplog.set_level(logging.INFO, logger=aliyun_trace_module.logger.name)
|
||||
with pytest.raises(ValueError, match=r"Aliyun get project url failed: boom"):
|
||||
trace_instance.get_project_url()
|
||||
logger_mock.info.assert_called()
|
||||
|
||||
assert "Aliyun get project url failed: boom" in caplog.text
|
||||
|
||||
|
||||
def test_workflow_trace_adds_workflow_and_node_spans(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
@@ -87,7 +88,6 @@ class PatchedCoreComponents(TypedDict):
|
||||
tracer: MagicMock
|
||||
span: MagicMock
|
||||
tracer_provider: MagicMock
|
||||
logger: MagicMock
|
||||
trace_api: Any
|
||||
|
||||
|
||||
@@ -148,9 +148,6 @@ def patch_core_components(monkeypatch: pytest.MonkeyPatch) -> PatchedCoreCompone
|
||||
resource = MagicMock(name="resource")
|
||||
monkeypatch.setattr(client_module, "Resource", MagicMock(return_value=resource))
|
||||
|
||||
logger_mock = MagicMock(name="tencent_logger")
|
||||
monkeypatch.setattr(client_module, "logger", logger_mock)
|
||||
|
||||
trace_api_stub = SimpleNamespace(
|
||||
set_span_in_context=MagicMock(name="set_span_in_context", return_value="trace-context"),
|
||||
NonRecordingSpan=MagicMock(name="non_recording_span", side_effect=lambda ctx: f"non-{ctx}"),
|
||||
@@ -174,7 +171,6 @@ def patch_core_components(monkeypatch: pytest.MonkeyPatch) -> PatchedCoreCompone
|
||||
"tracer": tracer,
|
||||
"span": span,
|
||||
"tracer_provider": tracer_provider,
|
||||
"logger": logger_mock,
|
||||
"trace_api": trace_api_stub,
|
||||
}
|
||||
|
||||
@@ -268,14 +264,15 @@ def test_record_methods_skip_when_histogram_missing() -> None:
|
||||
client.record_trace_duration(0.5)
|
||||
|
||||
|
||||
def test_record_llm_duration_handles_exceptions(patch_core_components: PatchedCoreComponents) -> None:
|
||||
def test_record_llm_duration_handles_exceptions(caplog: pytest.LogCaptureFixture) -> None:
|
||||
client = _build_client()
|
||||
client.hist_llm_duration = MagicMock(name="hist_llm_duration")
|
||||
client.hist_llm_duration.record.side_effect = RuntimeError("boom")
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger=client_module.logger.name)
|
||||
client.record_llm_duration(0.2)
|
||||
logger = patch_core_components["logger"]
|
||||
logger.debug.assert_called()
|
||||
|
||||
assert "[Tencent APM] Failed to record LLM duration" in caplog.text
|
||||
|
||||
|
||||
def test_create_and_export_span_sets_attributes(patch_core_components: PatchedCoreComponents) -> None:
|
||||
@@ -328,12 +325,15 @@ def test_create_and_export_span_uses_parent_context(patch_core_components: Patch
|
||||
trace_api.set_span_in_context.assert_called_once()
|
||||
|
||||
|
||||
def test_create_and_export_span_exception_logs_error(patch_core_components: PatchedCoreComponents) -> None:
|
||||
def test_create_and_export_span_exception_logs_error(
|
||||
patch_core_components: PatchedCoreComponents, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
client = _build_client()
|
||||
span = patch_core_components["span"]
|
||||
span.get_span_context.return_value = _make_span_context(span_id=2)
|
||||
client.tracer.start_span.side_effect = RuntimeError("boom")
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger=client_module.logger.name)
|
||||
client._create_and_export_span(
|
||||
SpanData(
|
||||
trace_id=1,
|
||||
@@ -346,8 +346,10 @@ def test_create_and_export_span_exception_logs_error(patch_core_components: Patc
|
||||
end_time=1,
|
||||
)
|
||||
)
|
||||
logger = patch_core_components["logger"]
|
||||
logger.exception.assert_called_once()
|
||||
|
||||
error_records = [record for record in caplog.records if record.levelno == logging.ERROR]
|
||||
assert len(error_records) == 1
|
||||
assert error_records[0].getMessage() == "[Tencent APM] Error creating span: span"
|
||||
|
||||
|
||||
def test_api_check_connects_successfully(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -423,23 +425,18 @@ def test_shutdown_flushes_all_components(patch_core_components: PatchedCoreCompo
|
||||
metric_reader.shutdown.assert_called_once()
|
||||
|
||||
|
||||
def test_shutdown_logs_when_meter_provider_fails(patch_core_components: PatchedCoreComponents) -> None:
|
||||
def test_shutdown_logs_when_meter_provider_fails(caplog: pytest.LogCaptureFixture) -> None:
|
||||
client = _build_client()
|
||||
meter_provider = meter_provider_instances[-1]
|
||||
meter_provider.shutdown.side_effect = RuntimeError("boom")
|
||||
assert client.metric_reader is not None
|
||||
client.metric_reader.shutdown.side_effect = RuntimeError("boom")
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger=client_module.logger.name)
|
||||
client.shutdown()
|
||||
logger = patch_core_components["logger"]
|
||||
logger.debug.assert_any_call(
|
||||
"[Tencent APM] Error shutting down meter provider",
|
||||
exc_info=True,
|
||||
)
|
||||
logger.debug.assert_any_call(
|
||||
"[Tencent APM] Error shutting down metric reader",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
assert "[Tencent APM] Error shutting down meter provider" in caplog.text
|
||||
assert "[Tencent APM] Error shutting down metric reader" in caplog.text
|
||||
|
||||
|
||||
def test_metrics_initialization_failure_sets_histogram_attributes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -456,10 +453,11 @@ def test_metrics_initialization_failure_sets_histogram_attributes(monkeypatch: p
|
||||
assert client.metric_reader is None
|
||||
|
||||
|
||||
def test_add_span_logs_exception(monkeypatch: pytest.MonkeyPatch, patch_core_components: PatchedCoreComponents) -> None:
|
||||
def test_add_span_logs_exception(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
|
||||
client = _build_client()
|
||||
monkeypatch.setattr(client, "_create_and_export_span", MagicMock(side_effect=RuntimeError("boom")))
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger=client_module.logger.name)
|
||||
client.add_span(
|
||||
SpanData(
|
||||
trace_id=1,
|
||||
@@ -473,8 +471,9 @@ def test_add_span_logs_exception(monkeypatch: pytest.MonkeyPatch, patch_core_com
|
||||
)
|
||||
)
|
||||
|
||||
logger = patch_core_components["logger"]
|
||||
logger.exception.assert_called_once()
|
||||
error_records = [record for record in caplog.records if record.levelno == logging.ERROR]
|
||||
assert len(error_records) == 1
|
||||
assert error_records[0].getMessage() == "[Tencent APM] Failed to create span: span"
|
||||
|
||||
|
||||
def test_create_and_export_span_converts_attribute_types(patch_core_components: PatchedCoreComponents) -> None:
|
||||
@@ -535,16 +534,20 @@ def test_record_trace_duration_converts_attributes() -> None:
|
||||
],
|
||||
)
|
||||
def test_record_methods_handle_exceptions(
|
||||
method: str, attr_name: str, args: tuple[object, ...], patch_core_components: PatchedCoreComponents
|
||||
method: str, attr_name: str, args: tuple[object, ...], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
client = _build_client()
|
||||
hist_mock = MagicMock(name=attr_name)
|
||||
hist_mock.record.side_effect = RuntimeError("boom")
|
||||
setattr(client, attr_name, hist_mock)
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger=client_module.logger.name)
|
||||
getattr(client, method)(*args)
|
||||
logger = patch_core_components["logger"]
|
||||
logger.debug.assert_called()
|
||||
|
||||
assert any(
|
||||
record.levelno == logging.DEBUG and record.getMessage().startswith("[Tencent APM] Failed to record")
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_metrics_initializes_grpc_metric_exporter() -> None:
|
||||
|
||||
@@ -45,6 +45,7 @@ class FileRequestService:
|
||||
user_from: UserFrom | str,
|
||||
invoke_from: InvokeFrom | str,
|
||||
file_mapping: Mapping[str, Any],
|
||||
for_external: bool = True,
|
||||
) -> DownloadFileRequestResult:
|
||||
"""Resolve one file mapping into signed download metadata.
|
||||
|
||||
@@ -61,7 +62,7 @@ class FileRequestService:
|
||||
)
|
||||
with bind_file_access_scope(scope):
|
||||
file = self._build_file(mapping=file_mapping, tenant_id=tenant_id)
|
||||
download_url = file_helpers.resolve_file_url(file, for_external=True)
|
||||
download_url = file_helpers.resolve_file_url(file, for_external=for_external)
|
||||
|
||||
if not download_url:
|
||||
raise ValueError("file does not support signed download")
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,325 +0,0 @@
|
||||
"""
|
||||
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 zipfile
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
import pyarrow.csv as pa_csv
|
||||
import pyarrow.parquet as pq
|
||||
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, 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"
|
||||
|
||||
|
||||
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."""
|
||||
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.READY:
|
||||
return task
|
||||
if task.status == WorkflowRunArchiveDownloadStatus.FAILED:
|
||||
logger.info("Skipping failed workflow run archive download task: %s", 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,
|
||||
}
|
||||
)
|
||||
self.cache.save(ready_task)
|
||||
return 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,
|
||||
}
|
||||
)
|
||||
self.cache.save(failed_task)
|
||||
return failed_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""
|
||||
buffer = io.BytesIO()
|
||||
pa_csv.write_csv(
|
||||
table,
|
||||
buffer,
|
||||
write_options=pa_csv.WriteOptions(include_header=include_header),
|
||||
)
|
||||
return buffer.getvalue()
|
||||
@@ -1,178 +0,0 @@
|
||||
"""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 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
|
||||
_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 create_if_absent(self, task: WorkflowRunArchiveDownloadTask) -> bool:
|
||||
ttl_seconds = self._ttl_seconds(task.expires_at)
|
||||
result = self._redis.set(
|
||||
self._cache_key(tenant_id=task.tenant_id, download_id=task.download_id),
|
||||
task.model_dump_json(),
|
||||
ex=ttl_seconds,
|
||||
nx=True,
|
||||
)
|
||||
return bool(result)
|
||||
|
||||
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]
|
||||
@@ -1,293 +0,0 @@
|
||||
"""
|
||||
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
|
||||
if task_cache.create_if_absent(task):
|
||||
return dispatch(task, task_cache)
|
||||
|
||||
existing = task_cache.get(tenant_id=tenant_id, download_id=download_id)
|
||||
if existing is not None:
|
||||
if existing.status == WorkflowRunArchiveDownloadStatus.FAILED:
|
||||
task_cache.save(task)
|
||||
return dispatch(task, task_cache)
|
||||
if existing.status == WorkflowRunArchiveDownloadStatus.PENDING and not existing.celery_task_id:
|
||||
return dispatch(existing, task_cache)
|
||||
return existing
|
||||
|
||||
task_cache.save(task)
|
||||
return dispatch(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 and persist the Celery id before the worker can start.
|
||||
|
||||
The Redis task key is the idempotency boundary. We generate the Celery id in the API process, save it on the task,
|
||||
then submit with that exact id so duplicate console requests keep seeing one logical download request.
|
||||
"""
|
||||
from tasks.workflow_run_archive_download_tasks import prepare_workflow_run_archive_download_task
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
celery_task_id = uuid.uuid4().hex
|
||||
queued_task = task.model_copy(update={"celery_task_id": celery_task_id, "updated_at": now})
|
||||
cache.save(queued_task)
|
||||
|
||||
try:
|
||||
prepare_workflow_run_archive_download_task.apply_async(
|
||||
args=(queued_task.tenant_id, queued_task.download_id),
|
||||
task_id=celery_task_id,
|
||||
)
|
||||
except Exception:
|
||||
failure_time = datetime.datetime.now(datetime.UTC)
|
||||
failed_task = queued_task.model_copy(
|
||||
update={
|
||||
"status": WorkflowRunArchiveDownloadStatus.FAILED,
|
||||
"error": "Failed to enqueue archive download task.",
|
||||
"updated_at": failure_time,
|
||||
"finished_at": failure_time,
|
||||
}
|
||||
)
|
||||
cache.save(failed_task)
|
||||
logger.exception("Failed to enqueue workflow run archive download task %s", queued_task.download_id)
|
||||
return failed_task
|
||||
|
||||
return queued_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 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.
|
||||
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.
|
||||
|
||||
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,12 +64,6 @@ 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,
|
||||
@@ -581,7 +575,6 @@ 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"
|
||||
@@ -605,7 +598,6 @@ 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"
|
||||
@@ -637,8 +629,6 @@ 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(
|
||||
@@ -662,23 +652,6 @@ 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,
|
||||
@@ -806,9 +779,9 @@ class WorkflowRunArchiver:
|
||||
identity: ArchiveBundleIdentity,
|
||||
runs: Sequence[WorkflowRun],
|
||||
table_stats: list[TableStats],
|
||||
) -> ArchiveBundleManifest:
|
||||
) -> ArchiveManifestDict:
|
||||
"""Generate a manifest for the archived workflow run bundle."""
|
||||
tables: dict[str, ArchiveBundleTableManifestEntry] = {
|
||||
tables: dict[str, TableStatsManifestEntry] = {
|
||||
stat.table_name: {
|
||||
"row_count": stat.row_count,
|
||||
"checksum": stat.checksum,
|
||||
@@ -821,9 +794,6 @@ class WorkflowRunArchiver:
|
||||
end_before = self.end_before
|
||||
if end_before is None:
|
||||
raise ValueError("archive window end must be set")
|
||||
archive_window_end = self._format_window_datetime(end_before)
|
||||
if archive_window_end is None:
|
||||
raise ValueError("archive window end must be set")
|
||||
return ArchiveManifestDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
@@ -843,7 +813,7 @@ class WorkflowRunArchiver:
|
||||
archived_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
campaign_id=self.campaign_id,
|
||||
archive_window_start=self._format_window_datetime(self.start_from),
|
||||
archive_window_end=archive_window_end,
|
||||
archive_window_end=end_before.isoformat(),
|
||||
run_shard=identity.shard,
|
||||
tables=tables,
|
||||
run_ids=[run.id for run in sorted_runs],
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""
|
||||
Maintain V2 workflow-run archive bundles.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""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)
|
||||
@@ -7,7 +7,6 @@ import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
import pytest
|
||||
|
||||
from models.workflow import WorkflowRunArchiveBundle
|
||||
from services.retention.workflow_run.archive_paid_plan_workflow_run import (
|
||||
ArchiveSummary,
|
||||
WorkflowRunArchiver,
|
||||
@@ -401,37 +400,6 @@ 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()
|
||||
|
||||
@@ -75,6 +75,7 @@ def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
# default values
|
||||
assert config.EDITION == "SELF_HOSTED"
|
||||
assert config.API_COMPRESSION_ENABLED is False
|
||||
assert config.AGENT_SHELL_ENABLED is True
|
||||
assert config.SENTRY_TRACES_SAMPLE_RATE == 1.0
|
||||
assert config.TEMPLATE_TRANSFORM_MAX_LENGTH == 400_000
|
||||
|
||||
@@ -110,6 +111,25 @@ def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
|
||||
assert config.HTTP_REQUEST_MAX_WRITE_TIMEOUT == 600
|
||||
|
||||
|
||||
def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.INTERNAL_FILES_URL == "http://api:5001"
|
||||
|
||||
|
||||
def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
monkeypatch.setenv("INTERNAL_FILES_URL", "http://files-internal:5001")
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.INTERNAL_FILES_URL == "http://files-internal:5001"
|
||||
|
||||
|
||||
# NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected.
|
||||
# This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`.
|
||||
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@@ -49,6 +49,8 @@ def make_message():
|
||||
msg.query = "hello"
|
||||
msg.re_sign_file_url_answer = ""
|
||||
msg.user_feedback = MagicMock(rating=None)
|
||||
msg.total_price = None
|
||||
msg.currency = None
|
||||
msg.status = "normal"
|
||||
msg.error = None
|
||||
return msg
|
||||
|
||||
@@ -34,11 +34,11 @@ class DummyFile:
|
||||
|
||||
|
||||
class DummyToolFile:
|
||||
def __init__(self):
|
||||
def __init__(self, name="test.txt", mimetype="text/plain"):
|
||||
self.id = "file-id"
|
||||
self.name = "test.txt"
|
||||
self.name = name
|
||||
self.size = 10
|
||||
self.mimetype = "text/plain"
|
||||
self.mimetype = mimetype
|
||||
self.original_url = "http://original"
|
||||
self.user_id = "user-1"
|
||||
self.tenant_id = "tenant-1"
|
||||
@@ -56,7 +56,7 @@ class TestPluginUploadFileApi:
|
||||
mock_get_user,
|
||||
mock_verify_signature,
|
||||
):
|
||||
dummy_file = DummyFile()
|
||||
dummy_file = DummyFile(filename="report.docx", mimetype="application/octet-stream")
|
||||
|
||||
module.request = fake_request(
|
||||
{
|
||||
@@ -71,7 +71,10 @@ class TestPluginUploadFileApi:
|
||||
)
|
||||
|
||||
tool_file_manager_instance = mock_tool_file_manager.return_value
|
||||
tool_file_manager_instance.create_file_by_raw.return_value = DummyToolFile()
|
||||
tool_file_manager_instance.create_file_by_raw.return_value = DummyToolFile(
|
||||
name="report.docx",
|
||||
mimetype="application/octet-stream",
|
||||
)
|
||||
|
||||
mock_tool_file_manager.sign_file.return_value = "signed-url"
|
||||
|
||||
@@ -84,10 +87,12 @@ class TestPluginUploadFileApi:
|
||||
assert result["id"] == "file-id"
|
||||
assert result["reference"] == build_file_reference(record_id="file-id")
|
||||
assert result["preview_url"] == "signed-url"
|
||||
assert result["extension"] == ".docx"
|
||||
mock_verify_signature.assert_called_once()
|
||||
assert mock_verify_signature.call_args.kwargs["conversation_id"] == "conversation-1"
|
||||
tool_file_manager_instance.create_file_by_raw.assert_called_once()
|
||||
assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1"
|
||||
mock_tool_file_manager.sign_file.assert_called_once_with(tool_file_id="file-id", extension=".docx")
|
||||
|
||||
def test_missing_file(self):
|
||||
module.request = fake_request(
|
||||
|
||||
@@ -318,6 +318,7 @@ class TestPluginDownloadFileRequestApi:
|
||||
mock_payload.user_id = "user-id"
|
||||
mock_payload.user_from = "account"
|
||||
mock_payload.invoke_from = "debugger"
|
||||
mock_payload.for_external = False
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
mock_payload.file.model_dump.return_value = {
|
||||
"transfer_method": "tool_file",
|
||||
@@ -333,6 +334,7 @@ class TestPluginDownloadFileRequestApi:
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": reference},
|
||||
for_external=False,
|
||||
)
|
||||
assert result["data"] == {
|
||||
"filename": "report.pdf",
|
||||
|
||||
@@ -37,6 +37,7 @@ from pydantic_ai.messages import (
|
||||
from clients.agent_backend import (
|
||||
AgentBackendError,
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunFailedInternalEvent,
|
||||
AgentBackendStreamInternalEvent,
|
||||
FakeAgentBackendRunClient,
|
||||
FakeAgentBackendScenario,
|
||||
@@ -54,6 +55,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueMessageEndEvent,
|
||||
)
|
||||
from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome
|
||||
from graphon.model_runtime.errors.invoke import InvokeRateLimitError
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import MessageAgentThought
|
||||
|
||||
@@ -1039,6 +1041,130 @@ def test_tool_result_without_call_id_matches_unique_open_tool_name(monkeypatch):
|
||||
assert rows[0].observation == "Knowledge base search results: browser skill"
|
||||
|
||||
|
||||
def test_repeated_tool_calls_without_call_id_or_index_create_distinct_rows(monkeypatch):
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
qm = _FakeQueueManager()
|
||||
recorder = app_runner_module._AgentProcessRecorder(
|
||||
dify_context=_dify_ctx(),
|
||||
message_id="msg-1",
|
||||
queue_manager=qm, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "function_tool_call",
|
||||
"part": {
|
||||
"part_kind": "tool-call",
|
||||
"tool_name": "shell_run",
|
||||
"args": {"script": "lookup find"},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "function_tool_result",
|
||||
"part": {
|
||||
"part_kind": "tool-return",
|
||||
"tool_name": "shell_run",
|
||||
"content": "find output",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "function_tool_call",
|
||||
"part": {
|
||||
"part_kind": "tool-call",
|
||||
"tool_name": "shell_run",
|
||||
"args": {"script": "lookup out"},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "function_tool_result",
|
||||
"part": {
|
||||
"part_kind": "tool-return",
|
||||
"tool_name": "shell_run",
|
||||
"content": "out output",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert len(rows) == 2
|
||||
assert rows[0].tool == "shell_run"
|
||||
assert rows[0].tool_input == '{"script": "lookup find"}'
|
||||
assert rows[0].observation == "find output"
|
||||
assert rows[1].tool == "shell_run"
|
||||
assert rows[1].tool_input == '{"script": "lookup out"}'
|
||||
assert rows[1].observation == "out output"
|
||||
|
||||
|
||||
def test_repeated_tool_calls_with_placeholder_call_id_and_reused_index_create_distinct_rows(monkeypatch):
|
||||
fake_session = _FakeDbSession()
|
||||
monkeypatch.setattr(app_runner_module.db, "session", fake_session)
|
||||
qm = _FakeQueueManager()
|
||||
recorder = app_runner_module._AgentProcessRecorder(
|
||||
dify_context=_dify_ctx(),
|
||||
message_id="msg-1",
|
||||
queue_manager=qm, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
for script, output in (("lookup find", "find output"), ("lookup out", "out output")):
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "function_tool_call",
|
||||
"index": 0,
|
||||
"part": {
|
||||
"part_kind": "tool-call",
|
||||
"tool_name": "shell_run",
|
||||
"tool_call_id": "None",
|
||||
"args": {"script": script},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
recorder.handle_stream_event(
|
||||
AgentBackendStreamInternalEvent(
|
||||
run_id="run-1",
|
||||
data={
|
||||
"event_kind": "function_tool_result",
|
||||
"part": {
|
||||
"part_kind": "tool-return",
|
||||
"tool_name": "shell_run",
|
||||
"tool_call_id": "None",
|
||||
"content": output,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert len(rows) == 2
|
||||
assert rows[0].tool == "shell_run"
|
||||
assert rows[0].tool_input == '{"script": "lookup find"}'
|
||||
assert rows[0].observation == "find output"
|
||||
assert rows[1].tool == "shell_run"
|
||||
assert rows[1].tool_input == '{"script": "lookup out"}'
|
||||
assert rows[1].observation == "out output"
|
||||
|
||||
|
||||
def test_prior_session_snapshot_is_threaded_into_request():
|
||||
prior = CompositorSessionSnapshot(layers=[])
|
||||
client = FakeAgentBackendRunClient()
|
||||
@@ -1088,6 +1214,19 @@ def test_failed_run_raises_agent_backend_error():
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
def test_agent_backend_failure_to_exception_maps_rate_limit_reason():
|
||||
err = app_runner_module._agent_backend_failure_to_exception(
|
||||
AgentBackendRunFailedInternalEvent(
|
||||
run_id="run-1",
|
||||
error="quota exceeded",
|
||||
reason="InvokeRateLimitError",
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(err, InvokeRateLimitError)
|
||||
assert str(err) == "quota exceeded"
|
||||
|
||||
|
||||
def test_stopped_task_cancels_agent_backend_run_and_skips_session_save():
|
||||
client = _RecordingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
|
||||
@@ -3,10 +3,11 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
from core.app.entities.queue_entities import QueueErrorEvent
|
||||
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
|
||||
from core.errors.error import QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
|
||||
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError, InvokeRateLimitError
|
||||
from models.enums import MessageStatus
|
||||
|
||||
|
||||
@@ -68,6 +69,11 @@ class TestBasedGenerateTaskPipeline:
|
||||
assert error_response.task_id == "task-1"
|
||||
assert ping_response.task_id == "task-1"
|
||||
|
||||
def test_stream_converter_maps_invoke_rate_limit_error(self):
|
||||
data = AppGenerateResponseConverter._error_to_stream_response(InvokeRateLimitError("quota exceeded"))
|
||||
|
||||
assert data == {"code": "rate_limit_error", "status": 429, "message": "quota exceeded"}
|
||||
|
||||
def test_handle_output_moderation_when_flagged(self, pipeline):
|
||||
handler = Mock()
|
||||
handler.moderation_completion.return_value = ("filtered", True)
|
||||
|
||||
@@ -22,6 +22,7 @@ def test_request_download_file_accepts_tool_file_reference() -> None:
|
||||
|
||||
assert payload.file.transfer_method == "tool_file"
|
||||
assert payload.file.reference == reference
|
||||
assert payload.for_external is True
|
||||
|
||||
|
||||
def test_request_download_file_accepts_remote_url() -> None:
|
||||
@@ -42,6 +43,25 @@ def test_request_download_file_accepts_remote_url() -> None:
|
||||
assert payload.file.url == "https://example.com/report.pdf"
|
||||
|
||||
|
||||
def test_request_download_file_accepts_internal_download_request() -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
payload = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": reference,
|
||||
},
|
||||
"for_external": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.for_external is False
|
||||
|
||||
|
||||
def test_request_download_file_rejects_remote_url_without_url() -> None:
|
||||
with pytest.raises(ValidationError, match="url is required"):
|
||||
_ = RequestRequestDownloadFile.model_validate(
|
||||
|
||||
@@ -60,6 +60,37 @@ def test_create_file_by_raw_stores_file_and_persists_record() -> None:
|
||||
session.refresh.assert_called_once_with(file_model)
|
||||
|
||||
|
||||
def test_create_file_by_raw_prefers_filename_extension_over_mimetype() -> None:
|
||||
manager = ToolFileManager()
|
||||
session = Mock()
|
||||
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-docx")
|
||||
|
||||
def tool_file_factory(**kwargs):
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
with (
|
||||
patch("core.tools.tool_file_manager.storage") as storage,
|
||||
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
|
||||
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="abc")),
|
||||
_patch_session_factory(session),
|
||||
):
|
||||
file_model = manager.create_file_by_raw(
|
||||
user_id="u1",
|
||||
tenant_id="t1",
|
||||
conversation_id="c1",
|
||||
file_binary=b"docx",
|
||||
mimetype="application/octet-stream",
|
||||
filename="report.docx",
|
||||
)
|
||||
|
||||
assert file_model.name == "report.docx"
|
||||
assert file_model.file_key == "tools/t1/abc.docx"
|
||||
storage.save.assert_called_once_with("tools/t1/abc.docx", b"docx")
|
||||
session.add.assert_called_once_with(file_model)
|
||||
session.commit.assert_called_once()
|
||||
session.refresh.assert_called_once_with(file_model)
|
||||
|
||||
|
||||
def test_create_file_by_url_downloads_and_persists_record() -> None:
|
||||
manager = ToolFileManager()
|
||||
response = Mock()
|
||||
@@ -88,6 +119,32 @@ def test_create_file_by_url_downloads_and_persists_record() -> None:
|
||||
session.refresh.assert_called_once_with(file_model)
|
||||
|
||||
|
||||
def test_create_file_by_url_prefers_url_extension_over_mimetype() -> None:
|
||||
manager = ToolFileManager()
|
||||
response = Mock()
|
||||
response.content = b"docx"
|
||||
response.headers = {"Content-Type": "application/octet-stream"}
|
||||
response.raise_for_status.return_value = None
|
||||
session = Mock()
|
||||
|
||||
def tool_file_factory(**kwargs):
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-docx")
|
||||
with (
|
||||
patch("core.tools.tool_file_manager.storage") as storage,
|
||||
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
|
||||
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="urlabc")),
|
||||
_patch_session_factory(session),
|
||||
patch("core.tools.tool_file_manager.remote_fetcher.make_request", return_value=response),
|
||||
):
|
||||
file_model = manager.create_file_by_url("u1", "t1", "https://example.com/report.docx?download=1", "c1")
|
||||
|
||||
assert file_model.file_key == "tools/t1/urlabc.docx"
|
||||
assert file_model.name == "urlabc.docx"
|
||||
storage.save.assert_called_once_with("tools/t1/urlabc.docx", b"docx")
|
||||
|
||||
|
||||
def test_create_file_by_url_raises_on_timeout() -> None:
|
||||
manager = ToolFileManager()
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
|
||||
|
||||
class _FakeToolFile:
|
||||
def __init__(self, mimetype: str):
|
||||
def __init__(self, mimetype: str, name: str | None):
|
||||
self.id = "fake-tool-file-id"
|
||||
self.mimetype = mimetype
|
||||
self.name = name or "fake-tool-file.bin"
|
||||
|
||||
|
||||
class _FakeToolFileManager:
|
||||
@@ -38,7 +39,7 @@ class _FakeToolFileManager:
|
||||
"mimetype": mimetype,
|
||||
"filename": filename,
|
||||
}
|
||||
return _FakeToolFile(mimetype)
|
||||
return _FakeToolFile(mimetype, filename)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -89,6 +90,29 @@ def test_transform_tool_invoke_messages_mimetype_key_present_but_none():
|
||||
assert o.meta["tool_file_id"] == "fake-tool-file-id"
|
||||
|
||||
|
||||
def test_transform_tool_invoke_messages_prefers_filename_extension_over_mimetype():
|
||||
msg = ToolInvokeMessage(
|
||||
type=ToolInvokeMessage.MessageType.BLOB,
|
||||
message=ToolInvokeMessage.BlobMessage(blob=b"docx"),
|
||||
meta={"mime_type": "application/octet-stream", "filename": "report.docx"},
|
||||
)
|
||||
|
||||
out = list(
|
||||
mt.ToolFileMessageTransformer.transform_tool_invoke_messages(
|
||||
messages=_gen([msg]),
|
||||
user_id="u1",
|
||||
tenant_id="t1",
|
||||
conversation_id="c1",
|
||||
)
|
||||
)
|
||||
|
||||
assert _FakeToolFileManager.last_call is not None
|
||||
assert _FakeToolFileManager.last_call["filename"] == "report.docx"
|
||||
assert len(out) == 1
|
||||
assert isinstance(out[0].message, ToolInvokeMessage.TextMessage)
|
||||
assert out[0].message.text.endswith(".docx")
|
||||
|
||||
|
||||
def test_transform_tool_invoke_messages_parses_existing_tool_file_link_meta():
|
||||
msg = ToolInvokeMessage(
|
||||
type=ToolInvokeMessage.MessageType.IMAGE_LINK,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.ask_human import AskHumanToolResult
|
||||
from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData
|
||||
from dify_agent.protocol import PydanticAIStreamRunEvent, RunStartedEvent, RunSucceededEvent, RunSucceededEventData
|
||||
from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendRunEventAdapter,
|
||||
@@ -190,6 +192,30 @@ class FileOutputBackendClient(FakeAgentBackendRunClient):
|
||||
)
|
||||
|
||||
|
||||
class AgentMessageDeltaBackendClient(FakeAgentBackendRunClient):
|
||||
def _events(self, run_id: str):
|
||||
created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
return (
|
||||
RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at),
|
||||
PydanticAIStreamRunEvent(
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
|
||||
agent_message_delta="hello ",
|
||||
),
|
||||
RunSucceededEvent(
|
||||
id="3-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=RunSucceededEventData(
|
||||
output={"text": "hello agent"},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _node(
|
||||
*,
|
||||
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||
@@ -277,6 +303,19 @@ def test_agent_node_run_maps_successful_agent_backend_run_to_node_result():
|
||||
assert layers["llm"]["config"]["credentials"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_agent_node_run_ignores_agent_message_delta_until_terminal_result():
|
||||
events = list(_node(agent_backend_client=AgentMessageDeltaBackendClient())._run())
|
||||
|
||||
assert len(events) == 1
|
||||
result = cast(StreamCompletedEvent, events[0]).node_run_result
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs == {"text": "hello agent"}
|
||||
agent_backend = result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG]["agent_backend"]
|
||||
assert agent_backend["status"] == "succeeded"
|
||||
assert agent_backend["agent_message_delta_count"] == 1
|
||||
assert agent_backend["agent_message_delta_length"] == len("hello ")
|
||||
|
||||
|
||||
def test_agent_node_run_normalizes_declared_file_output_with_canonical_mapping():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
with patch(
|
||||
|
||||
@@ -57,6 +57,17 @@ def test_reback_resolves_tenant_tool_file_to_file():
|
||||
assert file.extension == ".png"
|
||||
|
||||
|
||||
def test_reback_prefers_filename_extension_over_mimetype():
|
||||
tf = _seed(mimetype="application/octet-stream", name="report.docx", size=99)
|
||||
file = reback_tool_file_output(tenant_id=TENANT, tool_file_id=tf)
|
||||
|
||||
assert file is not None
|
||||
assert file.filename == "report.docx"
|
||||
assert file.mime_type == "application/octet-stream"
|
||||
assert file.extension == ".docx"
|
||||
assert file.type == FileType.CUSTOM
|
||||
|
||||
|
||||
def test_reback_other_tenant_returns_none():
|
||||
tf = _seed()
|
||||
assert reback_tool_file_output(tenant_id="33333333-3333-3333-3333-333333333333", tool_file_id=tf) is None
|
||||
|
||||
@@ -165,6 +165,20 @@ def test_build_from_mapping_accepts_opaque_related_id_for_tool_file(mock_tool_fi
|
||||
assert file.storage_key == "tool_file.pdf"
|
||||
|
||||
|
||||
def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(mock_tool_file):
|
||||
mock_tool_file.name = "report.docx"
|
||||
mock_tool_file.file_key = "tools/test_tenant_id/file.bin"
|
||||
mock_tool_file.mimetype = "application/octet-stream"
|
||||
mapping = tool_file_mapping(file_type="document")
|
||||
|
||||
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
|
||||
|
||||
assert file.extension == ".docx"
|
||||
assert file.filename == "report.docx"
|
||||
assert file.mime_type == "application/octet-stream"
|
||||
assert file.storage_key == "tools/test_tenant_id/file.bin"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_type", "should_pass", "expected_error"),
|
||||
[
|
||||
@@ -213,6 +227,25 @@ def test_build_from_remote_url(mock_http_head):
|
||||
assert file.size == 2048
|
||||
|
||||
|
||||
def test_build_from_remote_url_prefers_filename_extension_over_mimetype():
|
||||
mapping = {
|
||||
"transfer_method": "remote_url",
|
||||
"url": TEST_REMOTE_URL,
|
||||
"type": "document",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"factories.file_factory.builders.get_remote_file_info",
|
||||
return_value=("application/octet-stream", "report.docx", 99),
|
||||
):
|
||||
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
|
||||
|
||||
assert file.filename == "report.docx"
|
||||
assert file.extension == ".docx"
|
||||
assert file.mime_type == "application/octet-stream"
|
||||
assert file.size == 99
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_type", "should_pass", "expected_error"),
|
||||
[
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from fields.message_fields import ExploreMessageListItem, MessageListItem
|
||||
from decimal import Decimal
|
||||
|
||||
from fields.message_fields import ExploreMessageListItem, MessageListItem, WebMessageListItem
|
||||
|
||||
|
||||
def _base_kwargs():
|
||||
@@ -34,3 +36,33 @@ class TestExploreMessageListItem:
|
||||
# Guard the public service-API contract: the base item must not leak metadata.
|
||||
payload = MessageListItem(**_base_kwargs()).model_dump(mode="json")
|
||||
assert "metadata" not in payload
|
||||
|
||||
def test_message_list_item_exposes_usage_fields(self):
|
||||
payload = MessageListItem(
|
||||
**_base_kwargs(),
|
||||
message_tokens=7,
|
||||
answer_tokens=11,
|
||||
provider_response_latency=1.25,
|
||||
total_price=Decimal("0.0001234"),
|
||||
currency="USD",
|
||||
).model_dump(mode="json")
|
||||
|
||||
assert payload["message_tokens"] == 7
|
||||
assert payload["answer_tokens"] == 11
|
||||
assert payload["total_tokens"] == 18
|
||||
assert payload["provider_response_latency"] == 1.25
|
||||
assert payload["total_price"] == "0.0001234"
|
||||
assert payload["currency"] == "USD"
|
||||
|
||||
def test_web_message_list_item_exposes_usage_and_metadata(self):
|
||||
payload = WebMessageListItem(
|
||||
**_base_kwargs(),
|
||||
metadata={"usage": {"total_tokens": 18}},
|
||||
message_tokens=7,
|
||||
answer_tokens=11,
|
||||
).model_dump(mode="json")
|
||||
|
||||
assert payload["metadata"] == {"usage": {"total_tokens": 18}}
|
||||
assert payload["message_tokens"] == 7
|
||||
assert payload["answer_tokens"] == 11
|
||||
assert payload["total_tokens"] == 18
|
||||
|
||||
@@ -247,32 +247,6 @@ 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)
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -12,17 +11,10 @@ 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],
|
||||
path: str = "/console/api/test",
|
||||
):
|
||||
def __init__(self, headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]):
|
||||
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():
|
||||
@@ -71,24 +63,3 @@ 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")
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import datetime
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
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]
|
||||
|
||||
|
||||
class FakeSessionContext:
|
||||
session: MagicMock
|
||||
|
||||
def __init__(self, session: MagicMock) -> None:
|
||||
self.session = session
|
||||
|
||||
def __enter__(self) -> MagicMock:
|
||||
return self.session
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class FakeSessionFactory:
|
||||
session: MagicMock
|
||||
|
||||
def __init__(self, session: MagicMock) -> None:
|
||||
self.session = session
|
||||
|
||||
def __call__(self) -> FakeSessionContext:
|
||||
return FakeSessionContext(self.session)
|
||||
|
||||
|
||||
class FailingSessionFactory:
|
||||
def __call__(self) -> Iterator[MagicMock]:
|
||||
raise AssertionError("dry-run should not open a database session")
|
||||
|
||||
|
||||
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
|
||||
backfill = WorkflowRunArchiveBundleIndexBackfill(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, FakeSessionFactory(session)),
|
||||
)
|
||||
|
||||
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)
|
||||
),
|
||||
}
|
||||
)
|
||||
backfill = WorkflowRunArchiveBundleIndexBackfill(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, FailingSessionFactory()),
|
||||
)
|
||||
|
||||
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
|
||||
-273
@@ -1,273 +0,0 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
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 save(self, task: WorkflowRunArchiveDownloadTask) -> None:
|
||||
self.task = task
|
||||
self.saved_tasks.append(task)
|
||||
|
||||
|
||||
class FakeSessionContext:
|
||||
bundles: list[WorkflowRunArchiveBundle]
|
||||
|
||||
def __init__(self, bundles: list[WorkflowRunArchiveBundle]) -> None:
|
||||
self.bundles = bundles
|
||||
|
||||
def __enter__(self) -> "FakeSessionContext":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
return None
|
||||
|
||||
def scalars(self, stmt: object) -> list[WorkflowRunArchiveBundle]:
|
||||
return self.bundles
|
||||
|
||||
|
||||
class FakeSessionFactory:
|
||||
bundles: list[WorkflowRunArchiveBundle]
|
||||
|
||||
def __init__(self, bundles: list[WorkflowRunArchiveBundle]) -> None:
|
||||
self.bundles = bundles
|
||||
|
||||
def __call__(self) -> FakeSessionContext:
|
||||
return FakeSessionContext(self.bundles)
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
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
|
||||
return WorkflowRunArchiveDownloadPreparer(
|
||||
archive_storage=cast(ArchiveStorage, source_storage),
|
||||
download_storage=cast(ArchiveStorage, target_storage),
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
session_factory=cast(sessionmaker[Session], FakeSessionFactory(bundles or [_bundle()])),
|
||||
)
|
||||
|
||||
|
||||
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"}]),
|
||||
}
|
||||
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"}]),
|
||||
}
|
||||
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(
|
||||
task=task,
|
||||
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"') == 1
|
||||
assert '"run-a","succeeded"' in workflow_runs_csv
|
||||
assert '"run-b","failed"' 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(task=task, 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 == {}
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from services.retention.workflow_run.archive_download_task_cache import (
|
||||
ARCHIVE_DOWNLOAD_FORMAT_VERSION,
|
||||
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 = {}
|
||||
|
||||
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 set(
|
||||
self,
|
||||
name: str | bytes,
|
||||
value: str,
|
||||
ex: int | None = None,
|
||||
nx: bool = False,
|
||||
) -> object:
|
||||
key = name.decode("utf-8") if isinstance(name, bytes) else name
|
||||
if nx and key in self.store:
|
||||
return None
|
||||
self.store[key] = (ex or 0, 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 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_create_if_absent_is_idempotent() -> None:
|
||||
redis = FakeRedis()
|
||||
cache = WorkflowRunArchiveDownloadTaskCache(redis)
|
||||
first = 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",
|
||||
)
|
||||
second = first.model_copy(update={"archive_bytes": 2048})
|
||||
|
||||
assert cache.create_if_absent(first) is True
|
||||
assert cache.create_if_absent(second) is False
|
||||
|
||||
restored = cache.get(tenant_id="tenant-1", download_id="download-1")
|
||||
assert restored == first
|
||||
|
||||
|
||||
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
|
||||
@@ -1,326 +0,0 @@
|
||||
import datetime
|
||||
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:
|
||||
created_task: WorkflowRunArchiveDownloadTask | None
|
||||
saved_task: WorkflowRunArchiveDownloadTask | None
|
||||
existing_task: WorkflowRunArchiveDownloadTask | None
|
||||
tasks_by_download_id: dict[str, WorkflowRunArchiveDownloadTask]
|
||||
create_result: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
create_result: bool = True,
|
||||
existing_task: WorkflowRunArchiveDownloadTask | None = None,
|
||||
tasks_by_download_id: dict[str, WorkflowRunArchiveDownloadTask] | None = None,
|
||||
) -> None:
|
||||
self.created_task = None
|
||||
self.saved_task = None
|
||||
self.existing_task = existing_task
|
||||
self.tasks_by_download_id = tasks_by_download_id or {}
|
||||
self.create_result = create_result
|
||||
|
||||
def create_if_absent(self, task: WorkflowRunArchiveDownloadTask) -> bool:
|
||||
self.created_task = task
|
||||
return self.create_result
|
||||
|
||||
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
|
||||
|
||||
|
||||
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.model_copy(update={"celery_task_id": "celery-task-1"})
|
||||
|
||||
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.created_task == dispatched_tasks[0]
|
||||
assert task.celery_task_id == "celery-task-1"
|
||||
|
||||
|
||||
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(create_result=False, 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(create_result=False, 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 == "celery-task-1"
|
||||
assert cache.saved_task == dispatched_tasks[0]
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
@@ -59,6 +59,31 @@ def test_request_download_url_builds_file_under_bound_scope(
|
||||
assert result.download_url == "https://files.example.com/x"
|
||||
|
||||
|
||||
def test_request_download_url_supports_internal_download_urls() -> None:
|
||||
fake_file = MagicMock(filename="report.pdf", mime_type="application/pdf", size=123)
|
||||
service = FileRequestService(access_controller=MagicMock())
|
||||
|
||||
with (
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()),
|
||||
patch.object(service, "_build_file", return_value=fake_file),
|
||||
patch(
|
||||
"services.file_request_service.file_helpers.resolve_file_url",
|
||||
return_value="http://internal-files/report.pdf",
|
||||
) as resolve_file_url,
|
||||
):
|
||||
result = service.request_download_url(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:tool-file-1"},
|
||||
for_external=False,
|
||||
)
|
||||
|
||||
resolve_file_url.assert_called_once_with(fake_file, for_external=False)
|
||||
assert result.download_url == "http://internal-files/report.pdf"
|
||||
|
||||
|
||||
def test_request_download_url_rejects_unsupported_files() -> None:
|
||||
service = FileRequestService(access_controller=MagicMock())
|
||||
|
||||
|
||||
Generated
+22
-15
@@ -322,16 +322,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.11.0"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1284,7 +1283,9 @@ name = "dify-agent"
|
||||
version = "0.1.0"
|
||||
source = { editable = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
{ name = "typer" },
|
||||
@@ -1293,10 +1294,14 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", marker = "extra == 'shellctl-server'", specifier = ">=0.21.0,<1.0.0" },
|
||||
{ name = "anyio", specifier = ">=4.12.1,<5.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
|
||||
{ name = "fastapi", marker = "extra == 'shellctl-server'", specifier = "==0.136.0" },
|
||||
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.5.2" },
|
||||
{ name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "httpx2", specifier = ">=2.5.0,<3.0.0" },
|
||||
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
|
||||
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
|
||||
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
|
||||
@@ -1306,12 +1311,13 @@ requires-dist = [
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.4.0" },
|
||||
{ name = "sqlmodel", marker = "extra == 'shellctl-server'", specifier = ">=0.0.24,<0.1.0" },
|
||||
{ name = "typer", specifier = ">=0.16.1,<0.17" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'shellctl-server'", specifier = "==0.46.0" },
|
||||
]
|
||||
provides-extras = ["grpc", "server"]
|
||||
provides-extras = ["grpc", "server", "shellctl-server"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -3283,15 +3289,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.3.0"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3355,17 +3361,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.3.0"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpcore2" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3423,11 +3430,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+10
-4
@@ -20,6 +20,12 @@ DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
# API key sent to the Dify plugin daemon.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
|
||||
|
||||
# Dify API inner endpoints
|
||||
# Base URL for Dify API inner endpoints used by Agent Stub config/file/drive requests.
|
||||
DIFY_AGENT_INNER_API_URL=http://localhost:5001
|
||||
# Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY.
|
||||
DIFY_AGENT_INNER_API_KEY=
|
||||
|
||||
# Shell layer
|
||||
# Base URL for the shellctl server used by the dify.shell layer. Leave empty to disable shell layer use.
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=
|
||||
@@ -30,12 +36,12 @@ DIFY_AGENT_SHELLCTL_AUTH_TOKEN=
|
||||
# Public Agent Stub URL reachable from shellctl-managed remote machines.
|
||||
# Use http(s)://.../agent-stub for HTTP or grpc://host:port for gRPC.
|
||||
# Leave empty to avoid injecting DIFY_AGENT_STUB_* into shell.run jobs.
|
||||
DIFY_AGENT_STUB_URL=
|
||||
# Optional bind override used only when DIFY_AGENT_STUB_URL uses grpc://.
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://localhost:5050/agent-stub
|
||||
# Optional bind override used only when DIFY_AGENT_STUB_API_BASE_URL uses grpc://.
|
||||
DIFY_AGENT_STUB_GRPC_BIND_ADDRESS=
|
||||
# Server-wide root secret used to derive Agent Stub JWE keys.
|
||||
# Required when DIFY_AGENT_STUB_URL is set; must be unpadded base64url for 32 bytes.
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=
|
||||
# Required when DIFY_AGENT_STUB_API_BASE_URL is set; must be unpadded base64url for 32 bytes.
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
|
||||
# Shared plugin-daemon HTTP client timeouts and limits.
|
||||
# Plugin-daemon HTTP connect timeout in seconds.
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
# cd /app/api && .venv/bin/uvicorn dify_agent.server.app:app --host 0.0.0.0 --port 5050
|
||||
#
|
||||
# Unlike the dify-api image (which only installs the base `dify-agent`
|
||||
# dependency), this image installs the `[server]` extra, so jwcrypto,
|
||||
# shell-session-manager, fastapi, uvicorn, etc. are present and the server can
|
||||
# dependency), this image installs the `[server]` extra, so jwcrypto, fastapi,
|
||||
# uvicorn, etc. are present and the server can
|
||||
# actually start. dify-api is intentionally left lean.
|
||||
|
||||
# base image
|
||||
|
||||
@@ -12,19 +12,15 @@ FROM python:3.12-slim-bookworm AS base
|
||||
ARG NODE_VERSION=22.22.1
|
||||
ARG PNPM_VERSION=11.9.0
|
||||
ARG UV_VERSION=0.8.9
|
||||
ARG DIFY_AGENT_TOOL_SPEC=.[grpc]
|
||||
ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager==2.4.0
|
||||
ARG DIFY_AGENT_TOOL_SPEC=.[grpc,shellctl-server]
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive \
|
||||
UV_TOOL_DIR=/opt/dify-agent-tools/envs \
|
||||
UV_TOOL_BIN_DIR=/opt/dify-agent-tools/bin
|
||||
ENV PATH="${UV_TOOL_BIN_DIR}:${PATH}"
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
file \
|
||||
@@ -62,24 +58,25 @@ WORKDIR /opt/dify-agent
|
||||
FROM base AS tools
|
||||
|
||||
ARG DIFY_AGENT_TOOL_SPEC
|
||||
ARG SHELL_SESSION_MANAGER_TOOL_SPEC
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY src ./src
|
||||
|
||||
RUN uv export --frozen --no-dev --all-extras --no-emit-project --no-hashes \
|
||||
> /tmp/dify-agent-constraints.txt \
|
||||
&& uv tool install --force --python /usr/local/bin/python --no-python-downloads \
|
||||
&& UV_TOOL_DIR=/opt/dify-agent-tools/envs \
|
||||
UV_TOOL_BIN_DIR=/opt/dify-agent-tools/bin \
|
||||
uv tool install --force --python /usr/local/bin/python --no-python-downloads \
|
||||
--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${DIFY_AGENT_TOOL_SPEC}" \
|
||||
&& uv tool install --force --python /usr/local/bin/python --no-python-downloads \
|
||||
--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${SHELL_SESSION_MANAGER_TOOL_SPEC}" \
|
||||
&& rm -f /tmp/dify-agent-constraints.txt
|
||||
|
||||
|
||||
FROM base AS production
|
||||
|
||||
COPY --from=tools ${UV_TOOL_DIR} ${UV_TOOL_DIR}
|
||||
COPY --from=tools ${UV_TOOL_BIN_DIR} ${UV_TOOL_BIN_DIR}
|
||||
ENV PATH="/opt/dify-agent-tools/bin:${PATH}"
|
||||
|
||||
COPY --from=tools /opt/dify-agent-tools/envs /opt/dify-agent-tools/envs
|
||||
COPY --from=tools /opt/dify-agent-tools/bin /opt/dify-agent-tools/bin
|
||||
|
||||
RUN useradd --create-home --shell /bin/sh dify \
|
||||
&& mkdir -p /mnt/drive \
|
||||
|
||||
@@ -230,12 +230,11 @@ The provided `docker/local-sandbox/Dockerfile` installs:
|
||||
- `tmux`, required by `shellctl` to manage shell jobs;
|
||||
- common shell workspace tools: `git`, `openssh-client`, `jq`, `ripgrep`,
|
||||
`unzip`, `zip`, `file`, `procps`, and `less`;
|
||||
- `shell-session-manager==2.3.1` as a standalone uv tool, which provides the
|
||||
`shellctl` CLI/server;
|
||||
- `dify-agent[grpc,shellctl-server]` as a standalone uv tool, which provides
|
||||
both the Agent Stub client CLI and the built-in `shellctl` CLI/server;
|
||||
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
|
||||
workspace and Python CLI tools can be installed with isolated tool
|
||||
environments;
|
||||
- `node==22.22.1` and `pnpm==11.9.0`, so JavaScript and TypeScript tooling can
|
||||
run inside the shell workspace without per-job installation;
|
||||
- the `dify-agent[grpc]` Agent Stub client CLI as a standalone uv tool;
|
||||
- a non-root default user named `dify`.
|
||||
|
||||
@@ -36,6 +36,7 @@ message FileMapping {
|
||||
|
||||
message FileDownloadRequest {
|
||||
FileMapping file = 1;
|
||||
optional bool for_external = 2;
|
||||
}
|
||||
|
||||
message FileDownloadResponse {
|
||||
|
||||
@@ -5,7 +5,9 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
dependencies = [
|
||||
"anyio>=4.12.1,<5.0.0",
|
||||
"httpx==0.28.1",
|
||||
"httpx2>=2.5.0,<3.0.0",
|
||||
"pydantic>=2.12.5,<2.13",
|
||||
"pydantic-ai-slim>=1.102.0,<2.0.0",
|
||||
"typer>=0.16.1,<0.17",
|
||||
@@ -15,6 +17,9 @@ dependencies = [
|
||||
[project.scripts]
|
||||
dify-agent = "dify_agent.agent_stub.cli.main:main"
|
||||
dify-agent-stub-server = "dify_agent.agent_stub.server.cli:main"
|
||||
shellctl = "shellctl.cli:main"
|
||||
shellctl-sanitize-pty = "shellctl_runtime.sanitize:main"
|
||||
shellctl-runner-exit = "shellctl_runtime.runner_exit:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
grpc = ["grpclib[protobuf]>=0.4.9,<0.5.0", "protobuf>=6.33.5,<7.0.0"]
|
||||
@@ -27,13 +32,18 @@ server = [
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"shell-session-manager==2.4.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
]
|
||||
shellctl-server = [
|
||||
"aiosqlite>=0.21.0,<1.0.0",
|
||||
"fastapi==0.136.0",
|
||||
"sqlmodel>=0.0.24,<0.1.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["agenton*", "agenton_collections*", "dify_agent*"]
|
||||
include = ["agenton*", "agenton_collections*", "dify_agent*", "shellctl*", "shellctl_runtime*"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "examples", "tests"]
|
||||
|
||||
@@ -205,6 +205,7 @@ class DifyStreamedResponse(StreamedResponse):
|
||||
|
||||
@override
|
||||
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
|
||||
chunk_sequence = 0
|
||||
async for chunk in self.chunks:
|
||||
if chunk.delta.usage is not None:
|
||||
self._usage: RequestUsage = _map_usage(chunk.delta.usage)
|
||||
@@ -216,8 +217,10 @@ class DifyStreamedResponse(StreamedResponse):
|
||||
chunk,
|
||||
self.provider_name_value,
|
||||
self._embedded_thinking_parser,
|
||||
chunk_sequence,
|
||||
):
|
||||
yield event
|
||||
chunk_sequence += 1
|
||||
|
||||
for event in self._embedded_thinking_parser.flush(self._parts_manager, self.provider_name_value):
|
||||
yield event
|
||||
@@ -551,11 +554,21 @@ def _normalize_finish_reason(finish_reason: str) -> FinishReason:
|
||||
return "error"
|
||||
|
||||
|
||||
def _normalize_tool_call_id(tool_call_id: str | None) -> str | None:
|
||||
if tool_call_id is None:
|
||||
return None
|
||||
normalized = tool_call_id.strip()
|
||||
if not normalized or normalized.lower() in {"none", "null"}:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _chunk_to_stream_events(
|
||||
parts_manager: ModelResponsePartsManager,
|
||||
chunk: LLMResultChunk,
|
||||
provider_name: str,
|
||||
embedded_thinking_parser: "_EmbeddedThinkingParser",
|
||||
chunk_sequence: int,
|
||||
) -> list[ModelResponseStreamEvent]:
|
||||
events: list[ModelResponseStreamEvent] = []
|
||||
message = chunk.delta.message
|
||||
@@ -571,13 +584,14 @@ def _chunk_to_stream_events(
|
||||
events.append(parts_manager.handle_part(vendor_part_id=None, part=part))
|
||||
|
||||
for index, tool_call in enumerate(message.tool_calls):
|
||||
vendor_id = tool_call.id or f"chunk-{chunk.delta.index}-tool-{index}"
|
||||
tool_call_id = _normalize_tool_call_id(tool_call.id)
|
||||
vendor_id = tool_call_id or f"chunk-{chunk_sequence}-tool-{index}"
|
||||
events.append(
|
||||
parts_manager.handle_tool_call_part(
|
||||
vendor_part_id=vendor_id,
|
||||
tool_name=tool_call.function.name,
|
||||
args=tool_call.function.arguments,
|
||||
tool_call_id=tool_call.id,
|
||||
tool_call_id=tool_call_id or vendor_id,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Shellctl-backed shell provider adapter for dify-agent.
|
||||
|
||||
The shell-session-manager SDK owns the HTTP timeout policy for long-polling
|
||||
The built-in shellctl SDK owns the HTTP timeout policy for long-polling
|
||||
shellctl requests. This adapter stays narrowly focused on translating SDK and
|
||||
transport failures into ``ShellProviderError`` so the shell layer can return
|
||||
tool observations instead of aborting the agent loop.
|
||||
@@ -16,9 +16,9 @@ import time
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, TypeVar
|
||||
from typing import Protocol, TypeVar, cast
|
||||
|
||||
import httpx
|
||||
import httpx2 as httpx
|
||||
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellCommandProtocol,
|
||||
@@ -228,8 +228,16 @@ class ShellctlFileTransfer(ShellFileTransferProtocol):
|
||||
@dataclass(slots=True)
|
||||
class ShellctlResource(ShellResourceProtocol):
|
||||
client: ShellctlClientProtocol
|
||||
commands: ShellCommandProtocol
|
||||
files: ShellFileTransferProtocol
|
||||
_commands: ShellCommandProtocol
|
||||
_files: ShellFileTransferProtocol
|
||||
|
||||
@property
|
||||
def commands(self) -> ShellCommandProtocol:
|
||||
return self._commands
|
||||
|
||||
@property
|
||||
def files(self) -> ShellFileTransferProtocol:
|
||||
return self._files
|
||||
|
||||
async def close(self) -> None:
|
||||
try:
|
||||
@@ -257,8 +265,8 @@ class ShellctlProvider(ShellProviderProtocol):
|
||||
)
|
||||
return ShellctlResource(
|
||||
client=client,
|
||||
commands=ShellctlCommands(client=client),
|
||||
files=ShellctlFileTransfer(client=client),
|
||||
_commands=ShellctlCommands(client=client),
|
||||
_files=ShellctlFileTransfer(client=client),
|
||||
)
|
||||
|
||||
|
||||
@@ -269,9 +277,15 @@ def create_default_shellctl_client_factory(
|
||||
output_limit: int = _SHELLCTL_OUTPUT_LIMIT_BYTES,
|
||||
) -> ShellctlClientFactory:
|
||||
def factory() -> ShellctlClientProtocol:
|
||||
from shell_session_manager.shellctl.client import ShellctlClient
|
||||
from shellctl.client import ShellctlClient
|
||||
|
||||
return ShellctlClient(entrypoint, token=token, output_limit=output_limit)
|
||||
return cast(
|
||||
ShellctlClientProtocol,
|
||||
cast(
|
||||
object,
|
||||
ShellctlClient(entrypoint, token=token, output_limit=output_limit),
|
||||
),
|
||||
)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ def download_file_from_environment(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
file=file_mapping,
|
||||
for_external=False,
|
||||
)
|
||||
if not hasattr(download_request, "filename") or not isinstance(download_request.filename, str):
|
||||
raise AgentStubTransferError("signed file download response is missing filename")
|
||||
@@ -207,8 +208,10 @@ def _request_uploaded_tool_file_download_url(*, url: str, auth_jwe: str, referen
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=reference),
|
||||
),
|
||||
)
|
||||
if not hasattr(download_request, "download_url") or not isinstance(download_request.download_url, str):
|
||||
raise AgentStubTransferError("signed file download response is missing download_url")
|
||||
download_url = download_request.download_url
|
||||
if not isinstance(download_url, str) or not download_url:
|
||||
if not download_url:
|
||||
raise AgentStubTransferError("signed file download response is missing download_url")
|
||||
return download_url
|
||||
|
||||
|
||||
@@ -133,6 +133,26 @@ def config_skills_push(
|
||||
|
||||
Pass a directory such as ./skills/researcher that contains SKILL.md. Other files in that directory are
|
||||
archived with the skill. Pushing a skill with an existing name replaces that config skill.
|
||||
|
||||
Skill directory requirements:
|
||||
|
||||
- Each PATH must be one skill directory; the directory basename is the config skill name.
|
||||
|
||||
- The directory must contain a top-level SKILL.md.
|
||||
|
||||
- SKILL.md must be non-empty UTF-8 Markdown.
|
||||
|
||||
- SKILL.md must start with YAML frontmatter matching this schema:
|
||||
|
||||
\b
|
||||
---
|
||||
name: <non-empty string>
|
||||
description: <string>
|
||||
---
|
||||
|
||||
- Symlinked files are rejected.
|
||||
|
||||
- Dependency/cache folders such as .git, __pycache__, .venv and node_modules should be manually cleared before push.
|
||||
"""
|
||||
_run_config_skills_push(paths=paths)
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ def request_agent_stub_file_download_sync(
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
):
|
||||
@@ -109,12 +110,14 @@ def request_agent_stub_file_download_sync(
|
||||
url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
file=file,
|
||||
for_external=for_external,
|
||||
timeout=timeout,
|
||||
)
|
||||
return request_agent_stub_file_download_http_sync(
|
||||
base_url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
file=file,
|
||||
for_external=for_external,
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
|
||||
@@ -124,6 +124,7 @@ def request_agent_stub_file_download_grpc_sync(
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
):
|
||||
"""Request one signed download URL through the gRPC Agent Stub endpoint.
|
||||
@@ -144,7 +145,7 @@ def request_agent_stub_file_download_grpc_sync(
|
||||
auth_jwe=auth_jwe,
|
||||
method_name="CreateFileDownloadRequest",
|
||||
request_factory=lambda runtime: _require_conversions().proto_file_download_request(
|
||||
runtime.agent_stub_pb2, file=file
|
||||
runtime.agent_stub_pb2, file=file, for_external=for_external
|
||||
),
|
||||
response_parser=lambda response: _require_conversions().file_download_response_from_proto(response),
|
||||
timeout=timeout,
|
||||
|
||||
@@ -117,13 +117,14 @@ def request_agent_stub_file_download_http_sync(
|
||||
base_url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
) -> AgentStubFileDownloadResponse:
|
||||
"""Request one signed download URL from the HTTP Agent Stub endpoint."""
|
||||
|
||||
try:
|
||||
request_model = AgentStubFileDownloadRequest(file=file)
|
||||
request_model = AgentStubFileDownloadRequest(file=file, for_external=for_external)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubValidationError("invalid Agent Stub file download request") from exc
|
||||
response = _post_agent_stub_json(
|
||||
@@ -131,7 +132,7 @@ def request_agent_stub_file_download_http_sync(
|
||||
auth_jwe=auth_jwe,
|
||||
endpoint_name="file download request",
|
||||
endpoint_url_factory=agent_stub_file_download_request_url,
|
||||
request_body=request_model.model_dump_json(exclude_none=True),
|
||||
request_body=request_model.model_dump_json(exclude_none=True, exclude_defaults=True),
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# pyright: reportAttributeAccessIssue=false
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: dify/agent/stub/v1/agent_stub.proto
|
||||
# Protobuf Python Version: 6.33.5
|
||||
"""Generated protocol buffer code."""
|
||||
@@ -12,12 +12,7 @@ from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
33,
|
||||
5,
|
||||
"",
|
||||
"dify/agent/stub/v1/agent_stub.proto",
|
||||
_runtime_version.Domain.PUBLIC, 6, 33, 5, "", "dify/agent/stub/v1/agent_stub.proto"
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
@@ -25,12 +20,12 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
|
||||
b'\n#dify/agent/stub/v1/agent_stub.proto\x12\x12\x64ify.agent.stub.v1"O\n\x0e\x43onnectRequest\x12\x18\n\x10protocol_version\x18\x01 \x01(\x05\x12\x0c\n\x04\x61rgv\x18\x02 \x03(\t\x12\x15\n\rmetadata_json\x18\x03 \x01(\t"8\n\x0f\x43onnectResponse\x12\x15\n\rconnection_id\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t"7\n\x11\x46ileUploadRequest\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x10\n\x08mimetype\x18\x02 \x01(\t"(\n\x12\x46ileUploadResponse\x12\x12\n\nupload_url\x18\x01 \x01(\t"f\n\x0b\x46ileMapping\x12\x17\n\x0ftransfer_method\x18\x01 \x01(\t\x12\x16\n\treference\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x03url\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_referenceB\x06\n\x04_url"D\n\x13\x46ileDownloadRequest\x12-\n\x04\x66ile\x18\x01 \x01(\x0b\x32\x1f.dify.agent.stub.v1.FileMapping"r\n\x14\x46ileDownloadResponse\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x16\n\tmime_type\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x14\n\x0c\x64ownload_url\x18\x04 \x01(\tB\x0c\n\n_mime_type2\xc0\x02\n\x10\x41gentStubService\x12R\n\x07\x43onnect\x12".dify.agent.stub.v1.ConnectRequest\x1a#.dify.agent.stub.v1.ConnectResponse\x12h\n\x17\x43reateFileUploadRequest\x12%.dify.agent.stub.v1.FileUploadRequest\x1a&.dify.agent.stub.v1.FileUploadResponse\x12n\n\x19\x43reateFileDownloadRequest\x12\'.dify.agent.stub.v1.FileDownloadRequest\x1a(.dify.agent.stub.v1.FileDownloadResponseb\x06proto3'
|
||||
b'\n#dify/agent/stub/v1/agent_stub.proto\x12\x12\x64ify.agent.stub.v1"O\n\x0e\x43onnectRequest\x12\x18\n\x10protocol_version\x18\x01 \x01(\x05\x12\x0c\n\x04\x61rgv\x18\x02 \x03(\t\x12\x15\n\rmetadata_json\x18\x03 \x01(\t"8\n\x0f\x43onnectResponse\x12\x15\n\rconnection_id\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t"7\n\x11\x46ileUploadRequest\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x10\n\x08mimetype\x18\x02 \x01(\t"(\n\x12\x46ileUploadResponse\x12\x12\n\nupload_url\x18\x01 \x01(\t"f\n\x0b\x46ileMapping\x12\x17\n\x0ftransfer_method\x18\x01 \x01(\t\x12\x16\n\treference\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x03url\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_referenceB\x06\n\x04_url"p\n\x13\x46ileDownloadRequest\x12-\n\x04\x66ile\x18\x01 \x01(\x0b\x32\x1f.dify.agent.stub.v1.FileMapping\x12\x19\n\x0c\x66or_external\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_for_external"r\n\x14\x46ileDownloadResponse\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x16\n\tmime_type\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x14\n\x0c\x64ownload_url\x18\x04 \x01(\tB\x0c\n\n_mime_type2\xc0\x02\n\x10\x41gentStubService\x12R\n\x07\x43onnect\x12".dify.agent.stub.v1.ConnectRequest\x1a#.dify.agent.stub.v1.ConnectResponse\x12h\n\x17\x43reateFileUploadRequest\x12%.dify.agent.stub.v1.FileUploadRequest\x1a&.dify.agent.stub.v1.FileUploadResponse\x12n\n\x19\x43reateFileDownloadRequest\x12\'.dify.agent.stub.v1.FileDownloadRequest\x1a(.dify.agent.stub.v1.FileDownloadResponseb\x06proto3'
|
||||
)
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "dify_agent.agent_stub.grpc._generated.agent_stub_pb2", _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "dify.agent.stub.v1.agent_stub_pb2", _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals["_CONNECTREQUEST"]._serialized_start = 59
|
||||
@@ -44,9 +39,9 @@ if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals["_FILEMAPPING"]._serialized_start = 297
|
||||
_globals["_FILEMAPPING"]._serialized_end = 399
|
||||
_globals["_FILEDOWNLOADREQUEST"]._serialized_start = 401
|
||||
_globals["_FILEDOWNLOADREQUEST"]._serialized_end = 469
|
||||
_globals["_FILEDOWNLOADRESPONSE"]._serialized_start = 471
|
||||
_globals["_FILEDOWNLOADRESPONSE"]._serialized_end = 585
|
||||
_globals["_AGENTSTUBSERVICE"]._serialized_start = 588
|
||||
_globals["_AGENTSTUBSERVICE"]._serialized_end = 908
|
||||
_globals["_FILEDOWNLOADREQUEST"]._serialized_end = 513
|
||||
_globals["_FILEDOWNLOADRESPONSE"]._serialized_start = 515
|
||||
_globals["_FILEDOWNLOADRESPONSE"]._serialized_end = 629
|
||||
_globals["_AGENTSTUBSERVICE"]._serialized_start = 632
|
||||
_globals["_AGENTSTUBSERVICE"]._serialized_end = 952
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -1,71 +1,69 @@
|
||||
from __future__ import annotations
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
from collections.abc import Iterable
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
from google.protobuf.message import Message
|
||||
|
||||
|
||||
class ConnectRequest(Message):
|
||||
class ConnectRequest(_message.Message):
|
||||
__slots__ = ("protocol_version", "argv", "metadata_json")
|
||||
PROTOCOL_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
ARGV_FIELD_NUMBER: _ClassVar[int]
|
||||
METADATA_JSON_FIELD_NUMBER: _ClassVar[int]
|
||||
protocol_version: int
|
||||
argv: list[str]
|
||||
argv: _containers.RepeatedScalarFieldContainer[str]
|
||||
metadata_json: str
|
||||
def __init__(self, protocol_version: _Optional[int] = ..., argv: _Optional[_Iterable[str]] = ..., metadata_json: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
protocol_version: int = ...,
|
||||
argv: Iterable[str] = ...,
|
||||
metadata_json: str = ...,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ConnectResponse(Message):
|
||||
class ConnectResponse(_message.Message):
|
||||
__slots__ = ("connection_id", "status")
|
||||
CONNECTION_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
STATUS_FIELD_NUMBER: _ClassVar[int]
|
||||
connection_id: str
|
||||
status: str
|
||||
def __init__(self, connection_id: _Optional[str] = ..., status: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, connection_id: str = ..., status: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileUploadRequest(Message):
|
||||
class FileUploadRequest(_message.Message):
|
||||
__slots__ = ("filename", "mimetype")
|
||||
FILENAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MIMETYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
filename: str
|
||||
mimetype: str
|
||||
def __init__(self, filename: _Optional[str] = ..., mimetype: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, filename: str = ..., mimetype: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileUploadResponse(Message):
|
||||
class FileUploadResponse(_message.Message):
|
||||
__slots__ = ("upload_url",)
|
||||
UPLOAD_URL_FIELD_NUMBER: _ClassVar[int]
|
||||
upload_url: str
|
||||
def __init__(self, upload_url: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, upload_url: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileMapping(Message):
|
||||
class FileMapping(_message.Message):
|
||||
__slots__ = ("transfer_method", "reference", "url")
|
||||
TRANSFER_METHOD_FIELD_NUMBER: _ClassVar[int]
|
||||
REFERENCE_FIELD_NUMBER: _ClassVar[int]
|
||||
URL_FIELD_NUMBER: _ClassVar[int]
|
||||
transfer_method: str
|
||||
reference: str
|
||||
url: str
|
||||
def __init__(self, transfer_method: _Optional[str] = ..., reference: _Optional[str] = ..., url: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, transfer_method: str = ..., reference: str = ..., url: str = ...) -> None: ...
|
||||
def HasField(self, field_name: str) -> bool: ...
|
||||
|
||||
|
||||
class FileDownloadRequest(Message):
|
||||
class FileDownloadRequest(_message.Message):
|
||||
__slots__ = ("file", "for_external")
|
||||
FILE_FIELD_NUMBER: _ClassVar[int]
|
||||
FOR_EXTERNAL_FIELD_NUMBER: _ClassVar[int]
|
||||
file: FileMapping
|
||||
for_external: bool
|
||||
def __init__(self, file: _Optional[_Union[FileMapping, _Mapping]] = ..., for_external: _Optional[bool] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, file: FileMapping | None = ...) -> None: ...
|
||||
|
||||
|
||||
class FileDownloadResponse(Message):
|
||||
class FileDownloadResponse(_message.Message):
|
||||
__slots__ = ("filename", "mime_type", "size", "download_url")
|
||||
FILENAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MIME_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
SIZE_FIELD_NUMBER: _ClassVar[int]
|
||||
DOWNLOAD_URL_FIELD_NUMBER: _ClassVar[int]
|
||||
filename: str
|
||||
mime_type: str
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
filename: str = ...,
|
||||
mime_type: str = ...,
|
||||
size: int = ...,
|
||||
download_url: str = ...,
|
||||
) -> None: ...
|
||||
def HasField(self, field_name: str) -> bool: ...
|
||||
def __init__(self, filename: _Optional[str] = ..., mime_type: _Optional[str] = ..., size: _Optional[int] = ..., download_url: _Optional[str] = ...) -> None: ...
|
||||
|
||||
@@ -98,13 +98,19 @@ def file_download_request_from_proto(message: agent_stub_pb2.FileDownloadRequest
|
||||
"reference": message.file.reference if message.file.HasField("reference") else None,
|
||||
"url": message.file.url if message.file.HasField("url") else None,
|
||||
}
|
||||
return AgentStubFileDownloadRequest.model_validate({"file": file_mapping_kwargs})
|
||||
return AgentStubFileDownloadRequest.model_validate(
|
||||
{
|
||||
"file": file_mapping_kwargs,
|
||||
"for_external": message.for_external if message.HasField("for_external") else True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def proto_file_download_request(
|
||||
pb2_module,
|
||||
*,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
) -> agent_stub_pb2.FileDownloadRequest:
|
||||
"""Build one protobuf file-download request from the public DTO."""
|
||||
mapping = pb2_module.FileMapping(transfer_method=file.transfer_method)
|
||||
@@ -112,7 +118,9 @@ def proto_file_download_request(
|
||||
mapping.reference = file.reference
|
||||
if file.url is not None:
|
||||
mapping.url = file.url
|
||||
return pb2_module.FileDownloadRequest(file=mapping)
|
||||
request = pb2_module.FileDownloadRequest(file=mapping)
|
||||
request.for_external = for_external
|
||||
return request
|
||||
|
||||
|
||||
def file_download_response_from_proto(message: agent_stub_pb2.FileDownloadResponse) -> AgentStubFileDownloadResponse:
|
||||
|
||||
@@ -249,6 +249,7 @@ class AgentStubFileDownloadRequest(BaseModel):
|
||||
"""Request body for one signed download URL allocation."""
|
||||
|
||||
file: AgentStubFileMapping
|
||||
for_external: bool = True
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -161,6 +161,8 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
"invoke_from": execution_context.invoke_from,
|
||||
"file": request.file.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
if request.for_external is False:
|
||||
payload["for_external"] = False
|
||||
data = await self._post_inner_api("/inner/api/download/file/request", payload)
|
||||
try:
|
||||
return AgentStubFileDownloadResponse.model_validate(data)
|
||||
|
||||
@@ -141,7 +141,18 @@ shell_run script rules:
|
||||
|
||||
Tips:
|
||||
|
||||
- When using Python, prefer a uv script with a PEP 723 dependency header.
|
||||
- Python 3.12, uv, pip, Node.js, pnpm, and pnx are preinstalled in the local sandbox.
|
||||
- For one-off Python dependencies, prefer a uv script with a PEP 723 dependency header or:
|
||||
`uv run --with <package> python <script-or--c>`.
|
||||
- For reusable Python CLI tools, use `uv tool install <tool>`; installed commands land in `$HOME/.local/bin`.
|
||||
Run them by full path or add `$HOME/.local/bin` to PATH in the command that needs them.
|
||||
- `python3 -m pip install --user <package>` also installs into `$HOME/.local`; add `$HOME/.local/bin` to PATH
|
||||
when you need console scripts.
|
||||
- For reusable Node.js CLIs, use user-level global installs:
|
||||
`PNPM_HOME=$HOME/.local/share/pnpm PATH=$HOME/.local/share/pnpm/bin:$PATH pnpm add -g <package>`.
|
||||
Installed commands land in `$PNPM_HOME/bin`; run them by full path or with the same PATH prefix.
|
||||
- For one-off Node.js CLIs, prefer `pnx <command> [args]`.
|
||||
- Do not install new packages into system or image tool paths such as `/usr/local`, `/usr`, or `/opt/dify-agent-tools`.
|
||||
- If you need MCP, install the MCP server in the shell environment and start that server when you use it.
|
||||
|
||||
Example shell_run script:
|
||||
|
||||
@@ -31,13 +31,14 @@ both the JSON-safe final output or deferred tool call and the session snapshot;
|
||||
there are no separate output or snapshot events to correlate.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from collections.abc import AsyncIterable, Callable, Mapping
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from pydantic_ai.exceptions import ModelHTTPError
|
||||
from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta
|
||||
from pydantic_ai.output import OutputSpec
|
||||
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults
|
||||
@@ -104,6 +105,28 @@ class AgentRunValidationError(ValueError):
|
||||
"""Raised when a run request is valid JSON but cannot execute."""
|
||||
|
||||
|
||||
def _run_failed_error_payload(exc: Exception) -> tuple[str, str | None]:
|
||||
"""Return the public failed-run error text and structured reason."""
|
||||
message = str(exc) or type(exc).__name__
|
||||
reason: str | None = None
|
||||
|
||||
if isinstance(exc, ModelHTTPError):
|
||||
body = exc.body
|
||||
if isinstance(body, Mapping):
|
||||
body_message = body.get("message")
|
||||
if isinstance(body_message, str) and body_message:
|
||||
message = body_message
|
||||
|
||||
error_type = body.get("error_type")
|
||||
if isinstance(error_type, str) and error_type:
|
||||
reason = error_type
|
||||
|
||||
if reason is None and exc.status_code == 429:
|
||||
reason = "InvokeRateLimitError"
|
||||
|
||||
return message, reason
|
||||
|
||||
|
||||
def _has_model_layer(request: CreateRunRequest) -> bool:
|
||||
"""Return whether the public composition includes the reserved model layer."""
|
||||
return any(layer.name == DIFY_AGENT_MODEL_LAYER_ID for layer in request.composition.layers)
|
||||
@@ -165,8 +188,8 @@ class AgentRunRunner:
|
||||
try:
|
||||
outcome = await self._run_agent()
|
||||
except Exception as exc:
|
||||
message = str(exc) or type(exc).__name__
|
||||
_ = await emit_run_failed(self.sink, run_id=self.run_id, error=message)
|
||||
message, reason = _run_failed_error_payload(exc)
|
||||
_ = await emit_run_failed(self.sink, run_id=self.run_id, error=message, reason=reason)
|
||||
await self.sink.update_status(self.run_id, "failed", message)
|
||||
raise
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Public shellctl package exports.
|
||||
|
||||
This package stays lazy on purpose. Hot-path runtime helpers live outside the
|
||||
`shellctl` package, and importing this package root should
|
||||
not pull the full client/server/public DTO surface unless a caller explicitly
|
||||
asks for those exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.client import (
|
||||
ShellctlClient,
|
||||
ShellctlClientError,
|
||||
)
|
||||
from shellctl.shared import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_BASE_URL_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
TerminalSize,
|
||||
TerminateJobRequest,
|
||||
WaitJobRequest,
|
||||
generate_job_id,
|
||||
read_output_window,
|
||||
tail_output_window,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AUTH_TOKEN_ENV",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_BASE_URL_ENV",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS",
|
||||
"DEFAULT_LIST_LIMIT",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES",
|
||||
"DEFAULT_TERMINAL_COLS",
|
||||
"DEFAULT_TERMINAL_ROWS",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"DeleteJobResponse",
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
"ListJobsResponse",
|
||||
"RunJobRequest",
|
||||
"ShellctlClient",
|
||||
"ShellctlClientError",
|
||||
"TerminalSize",
|
||||
"TerminateJobRequest",
|
||||
"WaitJobRequest",
|
||||
"generate_job_id",
|
||||
"read_output_window",
|
||||
"tail_output_window",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"ShellctlClient": "shellctl.client",
|
||||
"ShellctlClientError": "shellctl.client",
|
||||
"DEFAULT_AUTH_TOKEN_ENV": "shellctl.shared",
|
||||
"DEFAULT_BASE_URL": "shellctl.shared",
|
||||
"DEFAULT_BASE_URL_ENV": "shellctl.shared",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_LIST_LIMIT": "shellctl.shared",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES": "shellctl.shared",
|
||||
"DEFAULT_TERMINAL_COLS": "shellctl.shared",
|
||||
"DEFAULT_TERMINAL_ROWS": "shellctl.shared",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_TIMEOUT_SECONDS": "shellctl.shared",
|
||||
"DeleteJobResponse": "shellctl.shared",
|
||||
"HealthResponse": "shellctl.shared",
|
||||
"InputJobRequest": "shellctl.shared",
|
||||
"JobInfo": "shellctl.shared",
|
||||
"JobResult": "shellctl.shared",
|
||||
"JobStatusName": "shellctl.shared",
|
||||
"JobStatusView": "shellctl.shared",
|
||||
"ListJobsResponse": "shellctl.shared",
|
||||
"RunJobRequest": "shellctl.shared",
|
||||
"TerminalSize": "shellctl.shared",
|
||||
"TerminateJobRequest": "shellctl.shared",
|
||||
"WaitJobRequest": "shellctl.shared",
|
||||
"generate_job_id": "shellctl.shared",
|
||||
"read_output_window": "shellctl.shared",
|
||||
"tail_output_window": "shellctl.shared",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@@ -0,0 +1,587 @@
|
||||
"""Typer CLI for network-backed shellctl commands.
|
||||
|
||||
Job-management commands in this module intentionally stay on the SDK side of
|
||||
the boundary: they parse CLI options, call `ShellctlClient`, and render compact
|
||||
JSON. That keeps `shellctl --help` and `shellctl run --help` free of FastAPI,
|
||||
SQLAlchemy, tmux, and local runtime bootstrap imports.
|
||||
|
||||
Only `serve` lazily imports server-side modules when that subcommand is
|
||||
actually invoked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
import anyio
|
||||
import httpx2 as httpx
|
||||
import typer
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from shellctl.client import ShellctlClient, ShellctlClientError
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_BASE_URL_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
RunJobRequest,
|
||||
TerminalSize,
|
||||
)
|
||||
|
||||
cli = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
pretty_exceptions_enable=False,
|
||||
rich_markup_mode=None,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("health")
|
||||
def health_command(
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help="Accepted for CLI consistency but ignored because /healthz is public.",
|
||||
),
|
||||
) -> None:
|
||||
"""Call the public health endpoint and report JSON."""
|
||||
|
||||
del auth_token
|
||||
|
||||
async def action(client: ShellctlClient) -> HealthResponse:
|
||||
return await client.health()
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=None,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("run")
|
||||
def run_command(
|
||||
script: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
cwd: Path | None = typer.Option(None, "--cwd"),
|
||||
env: list[str] | None = typer.Option(None, "--env"),
|
||||
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
||||
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
||||
idle_flush_seconds: float = typer.Option(
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
"--idle-flush-seconds",
|
||||
),
|
||||
cols: int | None = typer.Option(None, "--cols"),
|
||||
rows: int | None = typer.Option(None, "--rows"),
|
||||
) -> None:
|
||||
"""Create a job through the running shellctl server."""
|
||||
|
||||
request = _build_model(
|
||||
RunJobRequest,
|
||||
script=script,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=_parse_env(env),
|
||||
terminal=_terminal_size(cols=cols, rows=rows),
|
||||
timeout=timeout,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
)
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.run(
|
||||
request.script,
|
||||
cwd=request.cwd,
|
||||
env=request.env,
|
||||
timeout=request.timeout,
|
||||
terminal=request.terminal,
|
||||
)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("wait")
|
||||
def wait_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
offset: int = typer.Option(..., "--offset"),
|
||||
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
||||
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
||||
idle_flush_seconds: float = typer.Option(
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
"--idle-flush-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Wait for incremental output, completion, truncation, or timeout."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.wait(job_id, offset=offset, timeout=timeout)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("status")
|
||||
def status_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
) -> None:
|
||||
"""Materialize the current status view for one job."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobStatusView:
|
||||
return await client.status(job_id)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("list")
|
||||
def list_command(
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
status: JobStatusName | None = typer.Option(None, "--status"),
|
||||
limit: int = typer.Option(DEFAULT_LIST_LIMIT, "--limit", min=1, max=MAX_LIST_LIMIT),
|
||||
) -> None:
|
||||
"""List recent jobs, optionally filtered by lifecycle status."""
|
||||
|
||||
async def action(client: ShellctlClient) -> list[JobInfo]:
|
||||
return await client.list_jobs(status=status, limit=limit)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_job_list,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("input")
|
||||
def input_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
text: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
offset: int = typer.Option(..., "--offset"),
|
||||
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
||||
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
||||
idle_flush_seconds: float = typer.Option(
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
"--idle-flush-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Send text input to a running job and wait for the next result window."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.input(job_id, text, offset=offset, timeout=timeout)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("tail")
|
||||
def tail_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
output_limit: int = typer.Option(
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
"--output-limit",
|
||||
min=1,
|
||||
max=MAX_OUTPUT_LIMIT_BYTES,
|
||||
),
|
||||
) -> None:
|
||||
"""Read a UTF-8-safe output tail for one job."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.tail(job_id)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("terminate")
|
||||
def terminate_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
grace_seconds: float = typer.Option(
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
"--grace-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Terminate a job and return its materialized status."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobStatusView:
|
||||
return await client.terminate(job_id, grace_seconds=grace_seconds)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("delete")
|
||||
def delete_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
force: bool = typer.Option(False, "--force"),
|
||||
grace_seconds: float = typer.Option(
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
"--grace-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Delete a job row and artifacts, optionally terminating first."""
|
||||
|
||||
async def action(client: ShellctlClient) -> DeleteJobResponse:
|
||||
return await client.delete(
|
||||
job_id,
|
||||
force=force,
|
||||
grace_seconds=grace_seconds,
|
||||
)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("serve")
|
||||
def serve_command(
|
||||
listen: str = "127.0.0.1:8765",
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty to disable HTTP bearer auth."
|
||||
),
|
||||
),
|
||||
state_dir: Path | None = None,
|
||||
runtime_dir: Path | None = None,
|
||||
gc_interval_seconds: float = typer.Option(
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
"--gc-interval-seconds",
|
||||
),
|
||||
gc_finished_job_retention_seconds: float = typer.Option(
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
"--gc-finished-job-retention-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Run the local shellctl FastAPI server via uvicorn."""
|
||||
|
||||
from shellctl.server.serve import (
|
||||
serve_command as server_serve_command,
|
||||
)
|
||||
|
||||
server_serve_command(
|
||||
listen=listen,
|
||||
auth_token=auth_token,
|
||||
state_dir=state_dir,
|
||||
runtime_dir=runtime_dir,
|
||||
gc_interval_seconds=gc_interval_seconds,
|
||||
gc_finished_job_retention_seconds=gc_finished_job_retention_seconds,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entrypoint used by the console script and `python -m` invocations."""
|
||||
|
||||
cli()
|
||||
|
||||
|
||||
def _parse_env(values: list[str] | None) -> dict[str, str] | None:
|
||||
if not values:
|
||||
return None
|
||||
|
||||
parsed: dict[str, str] = {}
|
||||
for value in values:
|
||||
if "=" not in value:
|
||||
raise typer.BadParameter(
|
||||
"env entries must use NAME=VALUE format",
|
||||
param_hint="--env",
|
||||
)
|
||||
name, env_value = value.split("=", 1)
|
||||
if not name:
|
||||
raise typer.BadParameter(
|
||||
"env names must be non-empty",
|
||||
param_hint="--env",
|
||||
)
|
||||
parsed[name] = env_value
|
||||
return parsed
|
||||
|
||||
|
||||
def _terminal_size(*, cols: int | None, rows: int | None) -> TerminalSize | None:
|
||||
if cols is None and rows is None:
|
||||
return None
|
||||
return _build_model(
|
||||
TerminalSize,
|
||||
cols=cols if cols is not None else DEFAULT_TERMINAL_COLS,
|
||||
rows=rows if rows is not None else DEFAULT_TERMINAL_ROWS,
|
||||
)
|
||||
|
||||
|
||||
def _build_model[ModelT: BaseModel](model_type: type[ModelT], /, **data: object) -> ModelT:
|
||||
try:
|
||||
return model_type(**data)
|
||||
except ValidationError as exc:
|
||||
raise typer.BadParameter(_validation_error_message(exc)) from exc
|
||||
|
||||
|
||||
async def _with_client[ResponseT](
|
||||
base_url: str,
|
||||
auth_token: str | None,
|
||||
output_limit: int,
|
||||
idle_flush_seconds: float,
|
||||
action: Callable[[ShellctlClient], Awaitable[ResponseT]],
|
||||
) -> ResponseT:
|
||||
async with ShellctlClient(
|
||||
base_url,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
token=auth_token,
|
||||
) as client:
|
||||
return await action(client)
|
||||
|
||||
|
||||
def _run_client_action[ResponseT](
|
||||
*,
|
||||
base_url: str,
|
||||
auth_token: str | None,
|
||||
action: Callable[[ShellctlClient], Awaitable[ResponseT]],
|
||||
emit: Callable[[ResponseT], None],
|
||||
output_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
) -> None:
|
||||
try:
|
||||
payload = anyio.run(
|
||||
_with_client,
|
||||
base_url,
|
||||
auth_token,
|
||||
output_limit,
|
||||
idle_flush_seconds,
|
||||
action,
|
||||
)
|
||||
except ShellctlClientError as exc:
|
||||
_emit_error_and_exit(exc.code, exc.message)
|
||||
except httpx.TimeoutException:
|
||||
_emit_error_and_exit("request_timeout", "request timed out")
|
||||
except httpx.TransportError as exc:
|
||||
_emit_error_and_exit("connection_error", str(exc))
|
||||
|
||||
emit(payload)
|
||||
|
||||
|
||||
def _emit_model(model: BaseModel) -> None:
|
||||
typer.echo(model.model_dump_json(exclude_none=True), color=False)
|
||||
|
||||
|
||||
def _emit_job_list(jobs: list[JobInfo]) -> None:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
[item.model_dump(mode="json", exclude_none=True) for item in jobs],
|
||||
separators=(",", ":"),
|
||||
),
|
||||
color=False,
|
||||
)
|
||||
|
||||
|
||||
def _emit_error_and_exit(code: str, message: str) -> NoReturn:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{"error": {"code": code, "message": message}},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
err=True,
|
||||
color=False,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def _validation_error_message(exc: ValidationError) -> str:
|
||||
detail = exc.errors(include_url=False)[0]
|
||||
location = ".".join(str(part) for part in detail.get("loc", ()))
|
||||
message = detail["msg"]
|
||||
return f"{location}: {message}" if location else str(message)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"cli",
|
||||
"delete_command",
|
||||
"health_command",
|
||||
"input_command",
|
||||
"list_command",
|
||||
"main",
|
||||
"run_command",
|
||||
"serve_command",
|
||||
"status_command",
|
||||
"tail_command",
|
||||
"terminate_command",
|
||||
"wait_command",
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Async HTTP client package for shellctl.
|
||||
|
||||
`shellctl.client` remains importable as before, but it is
|
||||
now a package so future client helpers can live beside the main SDK class.
|
||||
"""
|
||||
|
||||
from shellctl.client.sdk import (
|
||||
ShellctlClient,
|
||||
ShellctlClientError,
|
||||
)
|
||||
|
||||
__all__ = ["ShellctlClient", "ShellctlClientError"]
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Async HTTP client for the shellctl server API.
|
||||
|
||||
The SDK keeps transport-level knobs (`output_limit`, `idle_flush_seconds`, base
|
||||
URL selection, and bearer-token handling) on the client instance so individual
|
||||
method calls stay close to the network CLI's high-level workflow. Blocking shell
|
||||
operations reuse the shared client, but they override the HTTP read timeout per
|
||||
request so the transport does not fail before the server-side shell wait timeout
|
||||
or terminate-grace budget does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx2 as httpx
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
TerminalSize,
|
||||
)
|
||||
|
||||
|
||||
class ShellctlClientError(RuntimeError):
|
||||
"""Raised for API-declared failures and response decode/shape problems.
|
||||
|
||||
`ShellctlClient` raises this error when the server returns a structured
|
||||
error payload, and also when an otherwise successful HTTP response contains
|
||||
invalid JSON or a top-level payload shape that does not match the SDK
|
||||
contract. Transport and timeout failures remain raw `httpx2` exceptions so
|
||||
library callers can decide how to handle network-layer failures.
|
||||
"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str) -> None:
|
||||
super().__init__(f"{code} ({status_code}): {message}")
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class ShellctlClient:
|
||||
"""Thin async SDK for the shellctl HTTP API.
|
||||
|
||||
The client owns a reusable `httpx.AsyncClient` unless one is injected via the
|
||||
`client` argument. Callers can therefore either keep one instance for a full
|
||||
workflow or treat it as an async context manager. Injected clients keep their
|
||||
original lifecycle; `close()` only closes clients that this SDK created.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
*,
|
||||
output_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
token: str | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
request_timeout_grace_seconds: float = 10.0,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.output_limit = output_limit
|
||||
self.idle_flush_seconds = idle_flush_seconds
|
||||
self.request_timeout_grace_seconds = request_timeout_grace_seconds
|
||||
self.token = token if token is not None else os.environ.get(DEFAULT_AUTH_TOKEN_ENV)
|
||||
self._owns_client = client is None
|
||||
self._client = client or httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(DEFAULT_TIMEOUT_SECONDS, connect=DEFAULT_TIMEOUT_SECONDS),
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> ShellctlClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client if this SDK instance owns it."""
|
||||
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
|
||||
def _wait_request_timeout(self, timeout: float) -> httpx.Timeout:
|
||||
"""Return a request timeout for blocking shell calls.
|
||||
|
||||
The shellctl server enforces the payload timeout, while the SDK keeps a
|
||||
small HTTP read-timeout grace so the transport can wait slightly longer
|
||||
for that response without loosening connect/write/pool timeouts.
|
||||
"""
|
||||
|
||||
return httpx.Timeout(
|
||||
connect=DEFAULT_TIMEOUT_SECONDS,
|
||||
read=timeout + self.request_timeout_grace_seconds,
|
||||
write=DEFAULT_TIMEOUT_SECONDS,
|
||||
pool=DEFAULT_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
def _terminate_request_timeout(self, grace_seconds: float | None = None) -> httpx.Timeout:
|
||||
"""Return a request timeout for terminate-style calls.
|
||||
|
||||
`terminate()` and forced `delete()` block until the server finishes the
|
||||
terminate grace window, so their HTTP read timeout must cover that
|
||||
business wait budget even when the request relies on the API default.
|
||||
"""
|
||||
|
||||
effective_grace_seconds = DEFAULT_TERMINATE_GRACE_SECONDS if grace_seconds is None else grace_seconds
|
||||
return self._wait_request_timeout(effective_grace_seconds)
|
||||
|
||||
async def health(self) -> HealthResponse:
|
||||
"""Call the public health endpoint and decode it as `HealthResponse`."""
|
||||
|
||||
return HealthResponse.model_validate(await self.healthz())
|
||||
|
||||
async def healthz(self) -> dict[str, Any]:
|
||||
"""Call the public health endpoint without requiring auth."""
|
||||
|
||||
response = await self._client.get("/healthz")
|
||||
return self._decode_response(response)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
terminal: TerminalSize | None = None,
|
||||
) -> JobResult:
|
||||
"""Create a new job and wait for initial output or completion.
|
||||
|
||||
`cwd` and `env` preset the script's working directory and environment
|
||||
overlay on the server side.
|
||||
"""
|
||||
|
||||
payload = RunJobRequest(
|
||||
script=script,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
terminal=terminal,
|
||||
timeout=timeout,
|
||||
output_limit=self.output_limit,
|
||||
idle_flush_seconds=self.idle_flush_seconds,
|
||||
)
|
||||
response = await self._client.post(
|
||||
"/v1/jobs/run",
|
||||
json=payload.model_dump(mode="json", exclude_none=True),
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._wait_request_timeout(timeout),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult:
|
||||
"""Wait for incremental output, completion, truncation, or timeout."""
|
||||
|
||||
response = await self._client.post(
|
||||
f"/v1/jobs/{job_id}/wait",
|
||||
json={
|
||||
"offset": offset,
|
||||
"timeout": timeout,
|
||||
"output_limit": self.output_limit,
|
||||
"idle_flush_seconds": self.idle_flush_seconds,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._wait_request_timeout(timeout),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def status(self, job_id: str) -> JobStatusView:
|
||||
"""Fetch the materialized status view for one job."""
|
||||
|
||||
response = await self._client.get(
|
||||
f"/v1/jobs/{job_id}",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
return JobStatusView.model_validate(self._decode_response(response))
|
||||
|
||||
async def list_jobs(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int = DEFAULT_LIST_LIMIT,
|
||||
) -> list[JobInfo]:
|
||||
"""List recent jobs, optionally filtered by lifecycle status."""
|
||||
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
if status is not None:
|
||||
params["status"] = status
|
||||
response = await self._client.get(
|
||||
"/v1/jobs",
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
payload = ListJobsResponse.model_validate(self._decode_response(response))
|
||||
return payload.jobs
|
||||
|
||||
async def input(
|
||||
self,
|
||||
job_id: str,
|
||||
text: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult:
|
||||
"""Send text input to a running job and then wait like `wait()`."""
|
||||
|
||||
response = await self._client.post(
|
||||
f"/v1/jobs/{job_id}/input",
|
||||
json={
|
||||
"text": text,
|
||||
"offset": offset,
|
||||
"timeout": timeout,
|
||||
"output_limit": self.output_limit,
|
||||
"idle_flush_seconds": self.idle_flush_seconds,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._wait_request_timeout(timeout),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def tail(self, job_id: str) -> JobResult:
|
||||
"""Fetch an immediate UTF-8-safe tail snapshot for a job."""
|
||||
|
||||
response = await self._client.get(
|
||||
f"/v1/jobs/{job_id}/log/tail",
|
||||
params={"output_limit": self.output_limit},
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def terminate(
|
||||
self,
|
||||
job_id: str,
|
||||
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
) -> JobStatusView:
|
||||
"""Terminate a job, waiting long enough for the grace window to finish."""
|
||||
|
||||
response = await self._client.post(
|
||||
f"/v1/jobs/{job_id}/terminate",
|
||||
json={"grace_seconds": grace_seconds},
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._terminate_request_timeout(grace_seconds),
|
||||
)
|
||||
return JobStatusView.model_validate(self._decode_response(response))
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
"""Delete job artifacts, optionally waiting for forced termination first."""
|
||||
|
||||
params: dict[str, Any] = {"force": str(force).lower()}
|
||||
if grace_seconds is not None:
|
||||
params["grace_seconds"] = grace_seconds
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"params": params,
|
||||
"headers": self._auth_headers(),
|
||||
}
|
||||
if force:
|
||||
request_kwargs["timeout"] = self._terminate_request_timeout(grace_seconds)
|
||||
response = await self._client.delete(
|
||||
f"/v1/jobs/{job_id}",
|
||||
**request_kwargs,
|
||||
)
|
||||
return DeleteJobResponse.model_validate(self._decode_response(response))
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
if not self.token:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _decode_response(self, response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc: # pragma: no cover - network/proxy corruption
|
||||
raise ShellctlClientError(response.status_code, "invalid_json", response.text) from exc
|
||||
|
||||
if response.is_error:
|
||||
error = payload.get("error") if isinstance(payload, dict) else None
|
||||
if isinstance(error, dict):
|
||||
code = str(error.get("code", "request_failed"))
|
||||
message = str(error.get("message", response.text))
|
||||
else:
|
||||
code = "request_failed"
|
||||
message = response.text
|
||||
raise ShellctlClientError(response.status_code, code, message)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ShellctlClientError(response.status_code, "invalid_payload", response.text)
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["ShellctlClient", "ShellctlClientError"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""shellctl server package.
|
||||
|
||||
The server stack sits behind lazy exports so importing the network CLI does not
|
||||
pull in FastAPI, SQLAlchemy, tmux, or the local service runtime unless a
|
||||
server-side symbol is actually used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.server.api import create_app
|
||||
from shellctl.server.cli import (
|
||||
cli,
|
||||
main,
|
||||
)
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
from shellctl.server.db import JobRow
|
||||
from shellctl.server.errors import ShellctlServerError
|
||||
from shellctl.server.serve import serve_command
|
||||
from shellctl.server.service import ShellctlService
|
||||
|
||||
__all__ = [
|
||||
"JobRow",
|
||||
"ShellctlConfig",
|
||||
"ShellctlServerError",
|
||||
"ShellctlService",
|
||||
"cli",
|
||||
"create_app",
|
||||
"main",
|
||||
"serve_command",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"JobRow": "shellctl.server.db",
|
||||
"ShellctlConfig": "shellctl.server.config",
|
||||
"ShellctlServerError": "shellctl.server.errors",
|
||||
"ShellctlService": "shellctl.server.service",
|
||||
"cli": "shellctl.server.cli",
|
||||
"create_app": "shellctl.server.api",
|
||||
"main": "shellctl.server.cli",
|
||||
"serve_command": "shellctl.server.serve",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Support `python -m shellctl.server`."""
|
||||
|
||||
from shellctl.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""FastAPI wiring for shellctl server endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated, cast
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
from shellctl.server.errors import ShellctlServerError
|
||||
from shellctl.server.service import ShellctlService
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_HEALTH_STATUS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
ErrorDetail,
|
||||
ErrorResponse,
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
TerminateJobRequest,
|
||||
WaitJobRequest,
|
||||
)
|
||||
|
||||
|
||||
def create_app(
|
||||
config: ShellctlConfig | None = None,
|
||||
*,
|
||||
service: ShellctlService | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create the FastAPI application used by `shellctl serve`."""
|
||||
|
||||
resolved_config = config or ShellctlConfig()
|
||||
resolved_service = service or ShellctlService(resolved_config)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await resolved_service.initialize()
|
||||
resolved_service.start_background_gc()
|
||||
resolved_service.start_background_pipe_monitor()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await resolved_service.shutdown()
|
||||
|
||||
app = FastAPI(title="shellctl", version="0.1.0", lifespan=lifespan)
|
||||
app.state.shellctl_service = resolved_service
|
||||
|
||||
@app.exception_handler(ShellctlServerError)
|
||||
async def handle_shellctl_error(
|
||||
_request: Request,
|
||||
exc: ShellctlServerError,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(error=ErrorDetail(code=exc.code, message=exc.message)).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
@app.exception_handler(RuntimeError)
|
||||
async def handle_runtime_error(_request: Request, exc: RuntimeError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="internal_error",
|
||||
message=str(exc) or "internal server error",
|
||||
)
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
def get_service() -> ShellctlService:
|
||||
return cast(ShellctlService, app.state.shellctl_service)
|
||||
|
||||
def verify_auth(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
token = resolved_config.auth_token
|
||||
if token is None:
|
||||
return
|
||||
expected = f"Bearer {token}"
|
||||
if authorization != expected:
|
||||
raise ShellctlServerError(401, "unauthorized", "Missing or invalid bearer token")
|
||||
|
||||
@app.get("/healthz", response_model=HealthResponse)
|
||||
async def healthz() -> HealthResponse:
|
||||
return HealthResponse(status=DEFAULT_HEALTH_STATUS)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/run",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def run_job(
|
||||
payload: RunJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.run_job(payload)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/{job_id}/wait",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def wait_job(
|
||||
job_id: str,
|
||||
payload: WaitJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.wait_job(job_id, payload)
|
||||
|
||||
@app.get(
|
||||
"/v1/jobs/{job_id}/log/tail",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def tail_job(
|
||||
job_id: str,
|
||||
output_limit: Annotated[int, Query(ge=1, le=MAX_OUTPUT_LIMIT_BYTES)] = DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.tail_job(job_id, output_limit=output_limit)
|
||||
|
||||
@app.get(
|
||||
"/v1/jobs/{job_id}",
|
||||
response_model=JobStatusView,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def job_status(
|
||||
job_id: str,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobStatusView:
|
||||
return await svc.get_job_status(job_id)
|
||||
|
||||
@app.get(
|
||||
"/v1/jobs",
|
||||
response_model=ListJobsResponse,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def list_jobs(
|
||||
status: Annotated[JobStatusName | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=MAX_LIST_LIMIT)] = DEFAULT_LIST_LIMIT,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> ListJobsResponse:
|
||||
return await svc.list_jobs(status=status, limit=limit)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/{job_id}/input",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def input_job(
|
||||
job_id: str,
|
||||
payload: InputJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.send_input(job_id, payload)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/{job_id}/terminate",
|
||||
response_model=JobStatusView,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def terminate_job(
|
||||
job_id: str,
|
||||
payload: TerminateJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobStatusView:
|
||||
return await svc.terminate_job(job_id, payload)
|
||||
|
||||
@app.delete(
|
||||
"/v1/jobs/{job_id}",
|
||||
response_model=DeleteJobResponse,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def delete_job(
|
||||
job_id: str,
|
||||
force: bool = False,
|
||||
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> DeleteJobResponse:
|
||||
return await svc.delete_job(job_id, force=force, grace_seconds=grace_seconds)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Per-job artifact names used by shellctl server/runtime code.
|
||||
|
||||
Normal job completion is coordinated through small marker files inside each
|
||||
`jobs/<job_id>/` directory so the tmux output-pipe finalizer can publish the
|
||||
SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
|
||||
drained into `output.log`. The same artifact directory also stores the request's
|
||||
environment overlay so the runner can merge arbitrary key/value pairs without
|
||||
shell-escaping them into the generated script. Separate failure markers and a
|
||||
dedicated `pipe-error.log` stderr capture keep startup diagnostics available
|
||||
when the sanitizer never reaches its ready-file handshake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
|
||||
RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
|
||||
JOB_ENV_FILENAME = ".job-env.json"
|
||||
PIPE_DRAINED_FILENAME = ".pipe-drained"
|
||||
PIPE_FAILED_FILENAME = ".pipe-failed"
|
||||
PIPE_ERROR_LOG_FILENAME = "pipe-error.log"
|
||||
|
||||
|
||||
def runner_exit_code_path(job_dir: Path) -> Path:
|
||||
return job_dir / RUNNER_EXIT_CODE_FILENAME
|
||||
|
||||
|
||||
def runner_ended_at_path(job_dir: Path) -> Path:
|
||||
return job_dir / RUNNER_ENDED_AT_FILENAME
|
||||
|
||||
|
||||
def job_env_path(job_dir: Path) -> Path:
|
||||
return job_dir / JOB_ENV_FILENAME
|
||||
|
||||
|
||||
def pipe_drained_path(job_dir: Path) -> Path:
|
||||
return job_dir / PIPE_DRAINED_FILENAME
|
||||
|
||||
|
||||
def pipe_failed_path(job_dir: Path) -> Path:
|
||||
return job_dir / PIPE_FAILED_FILENAME
|
||||
|
||||
|
||||
def pipe_error_log_path(job_dir: Path) -> Path:
|
||||
return job_dir / PIPE_ERROR_LOG_FILENAME
|
||||
|
||||
|
||||
__all__ = [
|
||||
"JOB_ENV_FILENAME",
|
||||
"PIPE_DRAINED_FILENAME",
|
||||
"PIPE_ERROR_LOG_FILENAME",
|
||||
"PIPE_FAILED_FILENAME",
|
||||
"RUNNER_ENDED_AT_FILENAME",
|
||||
"RUNNER_EXIT_CODE_FILENAME",
|
||||
"job_env_path",
|
||||
"pipe_drained_path",
|
||||
"pipe_error_log_path",
|
||||
"pipe_failed_path",
|
||||
"runner_ended_at_path",
|
||||
"runner_exit_code_path",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Compatibility shim for historical `shellctl.server.cli` imports.
|
||||
|
||||
Job-management commands now live in `shellctl.cli`. This
|
||||
module is intentionally a thin legacy re-export for callers that still import
|
||||
CLI symbols from `shellctl.server.cli`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shellctl.cli import cli, main
|
||||
from shellctl.server.serve import serve_command
|
||||
|
||||
__all__ = [
|
||||
"cli",
|
||||
"main",
|
||||
"serve_command",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Configuration objects for shellctl server/runtime modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
MAX_WAIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
from shellctl.shared.runtime import (
|
||||
default_runtime_dir,
|
||||
default_state_dir,
|
||||
)
|
||||
from shellctl_runtime.paths import DEFAULT_SQLITE_BUSY_TIMEOUT_MS
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ShellctlConfig:
|
||||
"""Runtime configuration for the shellctl service and CLI.
|
||||
|
||||
The tmux subprocess hooks use dedicated console scripts instead of
|
||||
`python -m shellctl...` entrypoints so every job does not pay
|
||||
the shellctl client/server import cost just to sanitize PTY bytes or record
|
||||
an exit row. `sqlite_busy_timeout_ms` still applies to the out-of-process
|
||||
`runner-exit` callback, so non-default deployments keep one SQLite timeout
|
||||
policy across the service and tmux finalizer.
|
||||
|
||||
Bearer auth is opt-in: if the explicit `auth_token` and the fallback
|
||||
`SHELLCTL_AUTH_TOKEN` environment variable are both missing or empty,
|
||||
`shellctl serve` accepts requests without checking an Authorization header.
|
||||
"""
|
||||
|
||||
listen: str = "127.0.0.1:8765"
|
||||
auth_token: str | None = None
|
||||
state_dir: Path = field(default_factory=default_state_dir)
|
||||
runtime_dir: Path | None = None
|
||||
gc_interval_seconds: float = DEFAULT_GC_INTERVAL_SECONDS
|
||||
gc_finished_job_retention_seconds: float = DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS
|
||||
default_timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
||||
max_wait_timeout_seconds: float = MAX_WAIT_TIMEOUT_SECONDS
|
||||
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS
|
||||
default_cwd: Path = field(default_factory=Path.home)
|
||||
default_terminal_cols: int = DEFAULT_TERMINAL_COLS
|
||||
default_terminal_rows: int = DEFAULT_TERMINAL_ROWS
|
||||
default_list_limit: int = DEFAULT_LIST_LIMIT
|
||||
max_list_limit: int = MAX_LIST_LIMIT
|
||||
default_output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES
|
||||
max_output_limit_bytes: int = MAX_OUTPUT_LIMIT_BYTES
|
||||
default_terminate_grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS
|
||||
poll_interval_seconds: float = 0.05
|
||||
pipe_monitor_interval_seconds: float = 1.0
|
||||
pipe_ready_timeout_seconds: float = 10.0
|
||||
sqlite_busy_timeout_ms: int = DEFAULT_SQLITE_BUSY_TIMEOUT_MS
|
||||
sanitize_pty_command: tuple[str, ...] = ("shellctl-sanitize-pty",)
|
||||
runner_exit_command: tuple[str, ...] = ("shellctl-runner-exit",)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.runtime_dir is None:
|
||||
object.__setattr__(self, "runtime_dir", default_runtime_dir(self.state_dir))
|
||||
token = self.auth_token
|
||||
if token is None:
|
||||
token = os.environ.get(DEFAULT_AUTH_TOKEN_ENV)
|
||||
if not token:
|
||||
token = None
|
||||
object.__setattr__(self, "auth_token", token)
|
||||
|
||||
@property
|
||||
def jobs_dir(self) -> Path:
|
||||
return self.state_dir / "jobs"
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
return self.state_dir / "shellctl.db"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return f"sqlite+aiosqlite:///{self.db_path}"
|
||||
|
||||
@property
|
||||
def tmux_socket(self) -> Path:
|
||||
runtime_dir = cast(Path, self.runtime_dir)
|
||||
return runtime_dir / "tmux.sock"
|
||||
|
||||
@property
|
||||
def runner_path(self) -> Path:
|
||||
runtime_dir = cast(Path, self.runtime_dir)
|
||||
return runtime_dir / "bin" / "shellctl-runner"
|
||||
|
||||
|
||||
__all__ = ["ShellctlConfig"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""SQLite models and engine helpers for shellctl."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class JobRow(SQLModel, table=True):
|
||||
"""SQLite source-of-truth row for shellctl jobs.
|
||||
|
||||
The table intentionally combines immutable metadata, mutable lifecycle state,
|
||||
and exit facts so state transitions can be expressed as single conditional
|
||||
`UPDATE` statements without synchronizing separate records.
|
||||
"""
|
||||
|
||||
__tablename__ = cast(Any, "jobs")
|
||||
|
||||
job_id: str = Field(primary_key=True)
|
||||
script_path: str
|
||||
output_path: str
|
||||
cwd: str
|
||||
terminal_cols: int
|
||||
terminal_rows: int
|
||||
status: str = Field(index=True)
|
||||
session_name: str
|
||||
pane_target: str
|
||||
exit_code: int | None = Field(default=None, nullable=True)
|
||||
reason: str | None = Field(default=None, nullable=True)
|
||||
message: str | None = Field(default=None, nullable=True)
|
||||
created_at: str = Field(index=True)
|
||||
started_at: str | None = Field(default=None, nullable=True)
|
||||
ended_at: str | None = Field(default=None, nullable=True, index=True)
|
||||
updated_at: str
|
||||
|
||||
|
||||
def configure_sqlite_engine(engine: AsyncEngine, *, busy_timeout_ms: int) -> None:
|
||||
"""Install SQLite pragmas required by the proposal's concurrency model."""
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_pragmas(dbapi_connection: Any, _connection_record: Any) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
|
||||
cursor.close()
|
||||
|
||||
|
||||
__all__ = ["JobRow", "configure_sqlite_engine"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared exception types for shellctl server-side modules."""
|
||||
|
||||
|
||||
class ShellctlServerError(RuntimeError):
|
||||
"""Structured server-side error that maps directly to API responses."""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
__all__ = ["ShellctlServerError"]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Server-side `shellctl serve` implementation.
|
||||
|
||||
This module stays separate from the top-level CLI so ordinary client commands
|
||||
do not import the FastAPI or uvicorn stack. `serve_command()` performs those
|
||||
imports lazily because only the long-running server path needs them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
)
|
||||
from shellctl.shared.runtime import (
|
||||
default_state_dir,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
|
||||
|
||||
def serve_command(
|
||||
listen: str = "127.0.0.1:8765",
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty to disable HTTP bearer auth."
|
||||
),
|
||||
),
|
||||
state_dir: Path | None = None,
|
||||
runtime_dir: Path | None = None,
|
||||
gc_interval_seconds: float = DEFAULT_GC_INTERVAL_SECONDS,
|
||||
gc_finished_job_retention_seconds: float = DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
) -> None:
|
||||
"""Build `ShellctlConfig` from CLI inputs and run the local HTTP server.
|
||||
|
||||
Args:
|
||||
listen: Host/port pair for the uvicorn listener in `host:port` form.
|
||||
auth_token: Optional bearer token value. An explicit empty string
|
||||
disables HTTP auth, an explicit non-empty token enables it, and an
|
||||
omitted/`None` value may still resolve from `SHELLCTL_AUTH_TOKEN`
|
||||
through the Typer env var binding or `ShellctlConfig` fallback.
|
||||
state_dir: Persistent shellctl state directory; defaults to the shared
|
||||
XDG-style state path when omitted.
|
||||
runtime_dir: Optional runtime directory override for tmux/runtime
|
||||
artifacts.
|
||||
gc_interval_seconds: Background GC wake-up cadence for finished jobs.
|
||||
gc_finished_job_retention_seconds: Retention window before finished jobs
|
||||
are eligible for GC.
|
||||
|
||||
This entrypoint is the only CLI path that should pull in the FastAPI and
|
||||
uvicorn stack. It parses the listener, constructs `ShellctlConfig`, and
|
||||
then hands the configured app to uvicorn.
|
||||
"""
|
||||
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
|
||||
host, port = _parse_listen(listen)
|
||||
config = ShellctlConfig(
|
||||
listen=listen,
|
||||
auth_token=auth_token,
|
||||
state_dir=state_dir or default_state_dir(),
|
||||
runtime_dir=runtime_dir,
|
||||
gc_interval_seconds=gc_interval_seconds,
|
||||
gc_finished_job_retention_seconds=gc_finished_job_retention_seconds,
|
||||
)
|
||||
_uvicorn_run(_create_app(config), host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
def _parse_listen(value: str) -> tuple[str, int]:
|
||||
if ":" not in value:
|
||||
raise typer.BadParameter("listen must use host:port format")
|
||||
host, raw_port = value.rsplit(":", 1)
|
||||
host = host.strip("[]")
|
||||
try:
|
||||
port = int(raw_port)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(f"invalid port: {raw_port}") from exc
|
||||
return host, port
|
||||
|
||||
|
||||
def _create_app(config: ShellctlConfig) -> Any:
|
||||
from shellctl.server.api import create_app
|
||||
|
||||
return create_app(config)
|
||||
|
||||
|
||||
def _uvicorn_run(app: Any, *, host: str, port: int, log_level: str) -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=host, port=port, log_level=log_level)
|
||||
|
||||
|
||||
__all__ = ["serve_command"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
"""tmux control layer for shellctl jobs.
|
||||
|
||||
The rest of shellctl treats this module as the only place that knows tmux CLI
|
||||
command shapes. Tests can replace `TmuxControllerProtocol` with a fake to
|
||||
exercise SQLite/output semantics without depending on a local tmux daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
|
||||
import anyio
|
||||
|
||||
from shellctl.server.artifacts import (
|
||||
pipe_drained_path,
|
||||
pipe_error_log_path,
|
||||
pipe_failed_path,
|
||||
runner_ended_at_path,
|
||||
runner_exit_code_path,
|
||||
)
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
from shellctl.server.errors import ShellctlServerError
|
||||
from shellctl.shared.runtime import (
|
||||
job_pane_target,
|
||||
job_session_name,
|
||||
)
|
||||
from shellctl.shared.schemas import TerminalSize
|
||||
|
||||
|
||||
class TmuxControllerProtocol(Protocol):
|
||||
"""Protocol used by `ShellctlService` for tmux interactions."""
|
||||
|
||||
async def start_server(self) -> None: ...
|
||||
|
||||
async def list_sessions(self) -> set[str]: ...
|
||||
|
||||
async def session_exists(self, session_name: str) -> bool: ...
|
||||
|
||||
async def is_output_pipe_active(self, *, job_id: str) -> bool | None: ...
|
||||
|
||||
async def create_job_session(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
job_dir: Path,
|
||||
cwd: Path,
|
||||
terminal: TerminalSize,
|
||||
) -> None: ...
|
||||
|
||||
async def enable_output_pipe(self, *, job_id: str, job_dir: Path, ready_file: Path) -> None: ...
|
||||
|
||||
async def send_input(self, *, job_id: str, text: str) -> None: ...
|
||||
|
||||
async def send_interrupt(self, *, job_id: str) -> None: ...
|
||||
|
||||
async def cleanup_session(self, *, job_id: str) -> None: ...
|
||||
|
||||
|
||||
class TmuxController:
|
||||
"""Best-effort wrapper around a dedicated tmux socket.
|
||||
|
||||
The controller always clears `TMUX` from the child environment and always
|
||||
passes `-S <socket>` so shellctl sessions stay isolated from the user's
|
||||
default tmux server.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ShellctlConfig) -> None:
|
||||
self._config = config
|
||||
|
||||
async def start_server(self) -> None:
|
||||
await self._run_tmux("start-server")
|
||||
|
||||
async def list_sessions(self) -> set[str]:
|
||||
result = await self._run_tmux("list-sessions", "-F", "#{session_name}", check=False)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
if _tmux_target_missing(stderr):
|
||||
return set()
|
||||
raise ShellctlServerError(500, "tmux_error", stderr.strip() or "tmux list-sessions failed")
|
||||
output = result.stdout.decode("utf-8", errors="replace")
|
||||
return {line.strip() for line in output.splitlines() if line.strip()}
|
||||
|
||||
async def session_exists(self, session_name: str) -> bool:
|
||||
return session_name in await self.list_sessions()
|
||||
|
||||
async def is_output_pipe_active(self, *, job_id: str) -> bool | None:
|
||||
result = await self._run_tmux(
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
"#{pane_pipe}",
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
if _tmux_target_missing(stderr):
|
||||
return None
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_error",
|
||||
stderr.strip() or f"Failed to inspect output pipe for {job_id}",
|
||||
)
|
||||
return result.stdout.decode("utf-8", errors="replace").strip() == "1"
|
||||
|
||||
async def create_job_session(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
job_dir: Path,
|
||||
cwd: Path,
|
||||
terminal: TerminalSize,
|
||||
) -> None:
|
||||
runner_command = self._shell_join(
|
||||
[
|
||||
str(self._config.runner_path),
|
||||
str(job_dir),
|
||||
job_id,
|
||||
str(cwd),
|
||||
]
|
||||
)
|
||||
result = await self._run_tmux(
|
||||
"-f",
|
||||
"/dev/null",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
job_session_name(job_id),
|
||||
"-x",
|
||||
str(terminal.cols),
|
||||
"-y",
|
||||
str(terminal.rows),
|
||||
runner_command,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_new_session_failed",
|
||||
result.stderr.decode("utf-8", errors="replace").strip()
|
||||
or f"Failed to create tmux session for {job_id}",
|
||||
)
|
||||
|
||||
async def enable_output_pipe(self, *, job_id: str, job_dir: Path, ready_file: Path) -> None:
|
||||
output_command = self._pipe_command_source(
|
||||
job_id=job_id,
|
||||
job_dir=job_dir,
|
||||
ready_file=ready_file,
|
||||
)
|
||||
result = await self._run_tmux(
|
||||
"pipe-pane",
|
||||
"-o",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
output_command,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"pipe_failed",
|
||||
result.stderr.decode("utf-8", errors="replace").strip() or f"Failed to attach output pipe for {job_id}",
|
||||
)
|
||||
|
||||
def _pipe_command_source(self, *, job_id: str, job_dir: Path, ready_file: Path) -> str:
|
||||
"""Build the tmux `pipe-pane` command that drains and finalizes output.
|
||||
|
||||
For normal exits, the runner now records completion metadata into job
|
||||
artifacts and the pipe finalizer commits `runner-exit` only after
|
||||
the lightweight sanitizer reaches EOF and flushes `output.log`
|
||||
successfully. Sanitizer stderr is captured into `pipe-error.log` so
|
||||
startup timeouts can distinguish slow imports from subprocess crashes.
|
||||
If the follow-up `runner-exit` write fails, the drain marker remains in
|
||||
place and stderr is appended to the same log with an explicit status
|
||||
line. The pipe still exits with the sanitizer status so a drained job is
|
||||
not misclassified as `pipe_failed` before reconciliation can recover the
|
||||
SQLite write from the drained artifacts.
|
||||
"""
|
||||
|
||||
sanitize_command = self._shell_join(
|
||||
(
|
||||
*self._config.sanitize_pty_command,
|
||||
"--ready-file",
|
||||
str(ready_file),
|
||||
)
|
||||
)
|
||||
runner_exit_command = self._shell_join(
|
||||
(
|
||||
*self._config.runner_exit_command,
|
||||
"--state-dir",
|
||||
str(self._config.state_dir),
|
||||
"--job-id",
|
||||
job_id,
|
||||
"--sqlite-busy-timeout-ms",
|
||||
str(self._config.sqlite_busy_timeout_ms),
|
||||
)
|
||||
)
|
||||
output_path = shlex.quote(str(job_dir / "output.log"))
|
||||
drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
|
||||
error_log_path = shlex.quote(str(pipe_error_log_path(job_dir)))
|
||||
failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
|
||||
exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
|
||||
ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
|
||||
return " ; ".join(
|
||||
[
|
||||
f"{sanitize_command} >> {output_path} 2> {error_log_path}",
|
||||
"sanitize_status=$?",
|
||||
"runner_exit_status=0",
|
||||
(
|
||||
'if [ "$sanitize_status" -eq 0 ]; then '
|
||||
f": > {drained_path}; "
|
||||
f"if [ -s {exit_code_path} ] && [ -s {ended_at_path} ]; then "
|
||||
f'{runner_exit_command} --exit-code "$(cat {exit_code_path})" '
|
||||
f'--ended-at "$(cat {ended_at_path})" 2>> {error_log_path}; '
|
||||
"runner_exit_status=$?; "
|
||||
'if [ "$runner_exit_status" -ne 0 ]; then '
|
||||
f"printf 'runner-exit failed with status %s\\n' \"$runner_exit_status\" >> {error_log_path}; "
|
||||
"fi; fi; "
|
||||
f"else : > {failed_path}; fi"
|
||||
),
|
||||
'if [ "$sanitize_status" -ne 0 ]; then exit "$sanitize_status"; fi',
|
||||
'exit "$sanitize_status"',
|
||||
]
|
||||
)
|
||||
|
||||
async def send_input(self, *, job_id: str, text: str) -> None:
|
||||
buffer_name = f"shellctl-in-{job_id}"
|
||||
runtime_dir = cast(Path, self._config.runtime_dir)
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f"shellctl-input-{job_id}-", dir=runtime_dir)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
load_result = await self._run_tmux(
|
||||
"load-buffer",
|
||||
"-b",
|
||||
buffer_name,
|
||||
str(tmp_path),
|
||||
check=False,
|
||||
)
|
||||
if load_result.returncode != 0:
|
||||
stderr = load_result.stderr.decode("utf-8", errors="replace").strip()
|
||||
if _tmux_target_missing(stderr):
|
||||
raise ShellctlServerError(
|
||||
409,
|
||||
"tmux_target_missing",
|
||||
stderr or f"The tmux pane for {job_id} is no longer available",
|
||||
)
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_input_failed",
|
||||
stderr or f"Failed to load input buffer for {job_id}",
|
||||
)
|
||||
paste_result = await self._run_tmux(
|
||||
"paste-buffer",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
"-b",
|
||||
buffer_name,
|
||||
check=False,
|
||||
)
|
||||
if paste_result.returncode != 0:
|
||||
stderr = paste_result.stderr.decode("utf-8", errors="replace").strip()
|
||||
if _tmux_target_missing(stderr):
|
||||
raise ShellctlServerError(
|
||||
409,
|
||||
"tmux_target_missing",
|
||||
stderr or f"The tmux pane for {job_id} is no longer available",
|
||||
)
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_input_failed",
|
||||
stderr or f"Failed to paste input buffer for {job_id}",
|
||||
)
|
||||
finally:
|
||||
await self._run_tmux("delete-buffer", "-b", buffer_name, check=False)
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
async def send_interrupt(self, *, job_id: str) -> None:
|
||||
await self._run_tmux(
|
||||
"send-keys",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
"C-c",
|
||||
check=False,
|
||||
)
|
||||
|
||||
async def cleanup_session(self, *, job_id: str) -> None:
|
||||
await self._run_tmux(
|
||||
"kill-session",
|
||||
"-t",
|
||||
job_session_name(job_id),
|
||||
check=False,
|
||||
)
|
||||
|
||||
async def _run_tmux(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
|
||||
env = dict(os.environ)
|
||||
env.pop("TMUX", None)
|
||||
try:
|
||||
result = await anyio.run_process(
|
||||
["tmux", "-S", str(self._config.tmux_socket), *args],
|
||||
env=env,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ShellctlServerError(500, "tmux_not_installed", "tmux executable was not found") from exc
|
||||
if check and result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_error",
|
||||
result.stderr.decode("utf-8", errors="replace").strip() or "tmux command failed",
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _shell_join(parts: tuple[str, ...] | list[str]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in parts)
|
||||
|
||||
|
||||
def _tmux_target_missing(stderr: str) -> bool:
|
||||
normalized = stderr.lower()
|
||||
return (
|
||||
"can't find pane" in normalized
|
||||
or "can't find session" in normalized
|
||||
or "no server running" in normalized
|
||||
or "failed to connect" in normalized
|
||||
or "server exited unexpectedly" in normalized
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TmuxController",
|
||||
"TmuxControllerProtocol",
|
||||
"_tmux_target_missing",
|
||||
]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Shared shellctl transport/runtime helpers.
|
||||
|
||||
This package preserves the historical import surface while keeping the package
|
||||
root lazy. Lightweight callers can import concrete submodules such as
|
||||
`shared.runtime` without eagerly importing the pydantic schema layer, output
|
||||
or helpers outside the shared compatibility surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_BASE_URL_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_HEALTH_STATUS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
JOB_ID_ALPHABET,
|
||||
JOB_ID_RANDOM_SUFFIX_LENGTH,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
MAX_WAIT_TIMEOUT_SECONDS,
|
||||
SESSION_NAME_PREFIX,
|
||||
)
|
||||
from shellctl.shared.output import (
|
||||
OutputWindow,
|
||||
read_output_window,
|
||||
tail_output_window,
|
||||
)
|
||||
from shellctl.shared.runtime import (
|
||||
default_runtime_dir,
|
||||
default_state_dir,
|
||||
format_timestamp,
|
||||
generate_job_id,
|
||||
is_terminal_status,
|
||||
job_pane_target,
|
||||
job_session_name,
|
||||
parse_timestamp,
|
||||
utc_now,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
TERMINAL_JOB_STATUSES,
|
||||
DeleteJobResponse,
|
||||
ErrorDetail,
|
||||
ErrorResponse,
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
ShellctlModel,
|
||||
TerminalSize,
|
||||
TerminateJobRequest,
|
||||
WaitJobRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AUTH_TOKEN_ENV",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_BASE_URL_ENV",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS",
|
||||
"DEFAULT_HEALTH_STATUS",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS",
|
||||
"DEFAULT_LIST_LIMIT",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES",
|
||||
"DEFAULT_TERMINAL_COLS",
|
||||
"DEFAULT_TERMINAL_ROWS",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"JOB_ID_ALPHABET",
|
||||
"JOB_ID_RANDOM_SUFFIX_LENGTH",
|
||||
"MAX_LIST_LIMIT",
|
||||
"MAX_OUTPUT_LIMIT_BYTES",
|
||||
"MAX_WAIT_TIMEOUT_SECONDS",
|
||||
"SESSION_NAME_PREFIX",
|
||||
"TERMINAL_JOB_STATUSES",
|
||||
"DeleteJobResponse",
|
||||
"ErrorDetail",
|
||||
"ErrorResponse",
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
"ListJobsResponse",
|
||||
"OutputWindow",
|
||||
"RunJobRequest",
|
||||
"ShellctlModel",
|
||||
"TerminalSize",
|
||||
"TerminateJobRequest",
|
||||
"WaitJobRequest",
|
||||
"default_runtime_dir",
|
||||
"default_state_dir",
|
||||
"format_timestamp",
|
||||
"generate_job_id",
|
||||
"is_terminal_status",
|
||||
"job_pane_target",
|
||||
"job_session_name",
|
||||
"parse_timestamp",
|
||||
"read_output_window",
|
||||
"tail_output_window",
|
||||
"utc_now",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"DEFAULT_AUTH_TOKEN_ENV": "shellctl.shared.constants",
|
||||
"DEFAULT_BASE_URL": "shellctl.shared.constants",
|
||||
"DEFAULT_BASE_URL_ENV": "shellctl.shared.constants",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_HEALTH_STATUS": "shellctl.shared.constants",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_LIST_LIMIT": "shellctl.shared.constants",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES": "shellctl.shared.constants",
|
||||
"DEFAULT_TERMINAL_COLS": "shellctl.shared.constants",
|
||||
"DEFAULT_TERMINAL_ROWS": "shellctl.shared.constants",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_TIMEOUT_SECONDS": "shellctl.shared.constants",
|
||||
"JOB_ID_ALPHABET": "shellctl.shared.constants",
|
||||
"JOB_ID_RANDOM_SUFFIX_LENGTH": "shellctl.shared.constants",
|
||||
"MAX_LIST_LIMIT": "shellctl.shared.constants",
|
||||
"MAX_OUTPUT_LIMIT_BYTES": "shellctl.shared.constants",
|
||||
"MAX_WAIT_TIMEOUT_SECONDS": "shellctl.shared.constants",
|
||||
"SESSION_NAME_PREFIX": "shellctl.shared.constants",
|
||||
"OutputWindow": "shellctl.shared.output",
|
||||
"read_output_window": "shellctl.shared.output",
|
||||
"tail_output_window": "shellctl.shared.output",
|
||||
"default_runtime_dir": "shellctl.shared.runtime",
|
||||
"default_state_dir": "shellctl.shared.runtime",
|
||||
"format_timestamp": "shellctl.shared.runtime",
|
||||
"generate_job_id": "shellctl.shared.runtime",
|
||||
"is_terminal_status": "shellctl.shared.runtime",
|
||||
"job_pane_target": "shellctl.shared.runtime",
|
||||
"job_session_name": "shellctl.shared.runtime",
|
||||
"parse_timestamp": "shellctl.shared.runtime",
|
||||
"utc_now": "shellctl.shared.runtime",
|
||||
"TERMINAL_JOB_STATUSES": "shellctl.shared.schemas",
|
||||
"DeleteJobResponse": "shellctl.shared.schemas",
|
||||
"ErrorDetail": "shellctl.shared.schemas",
|
||||
"ErrorResponse": "shellctl.shared.schemas",
|
||||
"HealthResponse": "shellctl.shared.schemas",
|
||||
"InputJobRequest": "shellctl.shared.schemas",
|
||||
"JobInfo": "shellctl.shared.schemas",
|
||||
"JobResult": "shellctl.shared.schemas",
|
||||
"JobStatusName": "shellctl.shared.schemas",
|
||||
"JobStatusView": "shellctl.shared.schemas",
|
||||
"ListJobsResponse": "shellctl.shared.schemas",
|
||||
"RunJobRequest": "shellctl.shared.schemas",
|
||||
"ShellctlModel": "shellctl.shared.schemas",
|
||||
"TerminalSize": "shellctl.shared.schemas",
|
||||
"TerminateJobRequest": "shellctl.shared.schemas",
|
||||
"WaitJobRequest": "shellctl.shared.schemas",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name) # noqa: no-new-getattr lazy export proxy
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared shellctl constants.
|
||||
|
||||
This module intentionally contains only literal defaults and bounds so callers
|
||||
can import stable configuration values without pulling in the heavier DTO,
|
||||
sanitize, or server packages. The network CLI depends on this file for its
|
||||
base URL and auth-token environment contract, so keep it import-light.
|
||||
"""
|
||||
|
||||
DEFAULT_AUTH_TOKEN_ENV = "SHELLCTL_AUTH_TOKEN"
|
||||
DEFAULT_BASE_URL_ENV = "SHELLCTL_BASE_URL"
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:8765"
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES = 1024 * 8
|
||||
MAX_OUTPUT_LIMIT_BYTES = 1024 * 1024
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
MAX_WAIT_TIMEOUT_SECONDS = 5.0 * 60.0
|
||||
DEFAULT_IDLE_FLUSH_SECONDS = 0.5
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS = 5.0
|
||||
DEFAULT_TERMINAL_COLS = 120
|
||||
DEFAULT_TERMINAL_ROWS = 80
|
||||
DEFAULT_LIST_LIMIT = 100
|
||||
MAX_LIST_LIMIT = 1000
|
||||
DEFAULT_GC_INTERVAL_SECONDS = 10.0 * 60.0
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS = 24.0 * 60.0 * 60.0
|
||||
JOB_ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
JOB_ID_RANDOM_SUFFIX_LENGTH = 3
|
||||
SESSION_NAME_PREFIX = "shellctl-job-"
|
||||
DEFAULT_HEALTH_STATUS = "ok"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AUTH_TOKEN_ENV",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_BASE_URL_ENV",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS",
|
||||
"DEFAULT_HEALTH_STATUS",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS",
|
||||
"DEFAULT_LIST_LIMIT",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES",
|
||||
"DEFAULT_TERMINAL_COLS",
|
||||
"DEFAULT_TERMINAL_ROWS",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"JOB_ID_ALPHABET",
|
||||
"JOB_ID_RANDOM_SUFFIX_LENGTH",
|
||||
"MAX_LIST_LIMIT",
|
||||
"MAX_OUTPUT_LIMIT_BYTES",
|
||||
"MAX_WAIT_TIMEOUT_SECONDS",
|
||||
"SESSION_NAME_PREFIX",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user