feat: expand module workflows, dynamic config, notifications and console API
This commit is contained in:
+178
-4
@@ -28,6 +28,11 @@ from gyxx_flow.core.locks import LockManager
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
from gyxx_flow.diagnostics import run_doctor
|
||||
from gyxx_flow.modules import create_default_registry as create_module_registry
|
||||
from gyxx_flow.modules.product_commerce import (
|
||||
TMALL_BAIBU_DEFAULT_IMPORT_MODE,
|
||||
TMALL_BAIBU_WORKFLOW_ID,
|
||||
validate_tmall_baibu_import_mode,
|
||||
)
|
||||
from gyxx_flow.ops import EffectLedger, EffectStateAmbiguous, RunIndex
|
||||
from gyxx_flow.scheduler_service import (
|
||||
PythonScheduler,
|
||||
@@ -94,6 +99,38 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="one argument passed to the script; repeat for multiple arguments",
|
||||
)
|
||||
|
||||
accounts_parser = commands.add_parser(
|
||||
"accounts",
|
||||
help="manage account-level browser login states shared by scripts",
|
||||
)
|
||||
account_commands = accounts_parser.add_subparsers(
|
||||
dest="accounts_command", required=True
|
||||
)
|
||||
accounts_list_parser = account_commands.add_parser("list", help="list accounts")
|
||||
accounts_list_parser.add_argument("--json", action="store_true")
|
||||
accounts_login_parser = account_commands.add_parser(
|
||||
"login", help="open the account login browser and save the vault state"
|
||||
)
|
||||
accounts_login_parser.add_argument("account_id")
|
||||
accounts_login_parser.add_argument(
|
||||
"--timeout", type=int, default=600, help="login wait timeout in seconds"
|
||||
)
|
||||
accounts_login_parser.add_argument("--target-url", help="page to open for login")
|
||||
accounts_login_parser.add_argument("--force", action="store_true")
|
||||
accounts_login_parser.add_argument("--json", action="store_true")
|
||||
accounts_sync_parser = account_commands.add_parser(
|
||||
"sync", help="push account vault cookies into member scripts"
|
||||
)
|
||||
accounts_sync_parser.add_argument("account_id", nargs="?")
|
||||
accounts_sync_parser.add_argument("--json", action="store_true")
|
||||
accounts_seed_parser = account_commands.add_parser(
|
||||
"seed", help="import one existing script browser state into an account vault"
|
||||
)
|
||||
accounts_seed_parser.add_argument("account_id")
|
||||
accounts_seed_parser.add_argument("--source-binding", required=True)
|
||||
accounts_seed_parser.add_argument("--force", action="store_true")
|
||||
accounts_seed_parser.add_argument("--json", action="store_true")
|
||||
|
||||
effects_parser = commands.add_parser(
|
||||
"effects", help="inspect or reconcile guarded production effects"
|
||||
)
|
||||
@@ -196,17 +233,29 @@ def _add_execution_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
help="execute registered steps; omitted means no-side-effect dry-run",
|
||||
)
|
||||
parser.add_argument("--shadow", action="store_true")
|
||||
parser.add_argument(
|
||||
"--tmall-baibu-import-mode",
|
||||
choices=("all", "without_hyperlinks"),
|
||||
help="Tmall Baibu scope: all entries or only entries without hyperlinks",
|
||||
)
|
||||
recovery = parser.add_mutually_exclusive_group()
|
||||
recovery.add_argument("--resume-from", metavar="STEP_ID")
|
||||
recovery.add_argument("--rerun-step", metavar="STEP_ID")
|
||||
|
||||
|
||||
def build_default_registry(settings: Settings) -> WorkflowRegistry:
|
||||
def build_default_registry(
|
||||
settings: Settings,
|
||||
*,
|
||||
tmall_baibu_import_mode: str | None = None,
|
||||
) -> WorkflowRegistry:
|
||||
"""Compose only workflows explicitly implemented by migrated modules."""
|
||||
|
||||
catalog = WorkflowCatalog.load(settings.project_root / "config")
|
||||
registry = WorkflowRegistry(catalog)
|
||||
modules = create_module_registry(catalog=catalog)
|
||||
modules = create_module_registry(
|
||||
catalog=catalog,
|
||||
tmall_baibu_import_mode=tmall_baibu_import_mode,
|
||||
)
|
||||
for workflow_id in modules.workflow_ids:
|
||||
registry.register(modules.workflow(workflow_id))
|
||||
return registry
|
||||
@@ -259,7 +308,33 @@ def main(
|
||||
return _effects_reconcile_command(
|
||||
arguments, resolved_settings, output
|
||||
)
|
||||
resolved_registry = registry or build_default_registry(resolved_settings)
|
||||
if arguments.command == "accounts":
|
||||
if arguments.accounts_command == "list":
|
||||
return _accounts_list_command(arguments, resolved_settings, output)
|
||||
if arguments.accounts_command == "login":
|
||||
return _accounts_login_command(arguments, resolved_settings, output)
|
||||
if arguments.accounts_command == "sync":
|
||||
return _accounts_sync_command(arguments, resolved_settings, output)
|
||||
if arguments.accounts_command == "seed":
|
||||
return _accounts_seed_command(arguments, resolved_settings, output)
|
||||
raise CliConfigurationError(
|
||||
f"unsupported accounts command: {arguments.accounts_command}"
|
||||
)
|
||||
tmall_baibu_import_mode: str | None = None
|
||||
if arguments.command in {"run", "backfill"}:
|
||||
raw_import_mode = getattr(arguments, "tmall_baibu_import_mode", None)
|
||||
if raw_import_mode is not None and arguments.workflow_id != TMALL_BAIBU_WORKFLOW_ID:
|
||||
raise CliConfigurationError(
|
||||
"--tmall-baibu-import-mode 仅适用于 product.tmall_baibu_apply"
|
||||
)
|
||||
if arguments.workflow_id == TMALL_BAIBU_WORKFLOW_ID:
|
||||
tmall_baibu_import_mode = validate_tmall_baibu_import_mode(
|
||||
raw_import_mode or TMALL_BAIBU_DEFAULT_IMPORT_MODE
|
||||
)
|
||||
resolved_registry = registry or build_default_registry(
|
||||
resolved_settings,
|
||||
tmall_baibu_import_mode=tmall_baibu_import_mode,
|
||||
)
|
||||
if arguments.command == "list":
|
||||
return _list_workflows(arguments, resolved_registry, output)
|
||||
if arguments.command == "run":
|
||||
@@ -302,7 +377,12 @@ def _load_runtime_environment_files(arguments: argparse.Namespace) -> None:
|
||||
for key, value in values.items():
|
||||
if not isinstance(key, str) or not key or value is None:
|
||||
continue
|
||||
os.environ.setdefault(key, value)
|
||||
# A service wrapper may export an empty placeholder for a secret
|
||||
# or database setting. Treat that as unset so the explicitly
|
||||
# requested runtime file can supply it; a non-empty process value
|
||||
# remains authoritative.
|
||||
if not os.environ.get(key, "").strip():
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _doctor_command(
|
||||
@@ -342,6 +422,89 @@ def _effects_reconcile_command(
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def _accounts_list_command(
|
||||
arguments: argparse.Namespace,
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
) -> int:
|
||||
from gyxx_flow.accounts import list_accounts
|
||||
|
||||
payload = list_accounts(settings)
|
||||
if arguments.json:
|
||||
_write_json(output, payload)
|
||||
else:
|
||||
for item in payload["accounts"]:
|
||||
state = "valid" if item["vault_valid"] else (
|
||||
"missing" if not item["vault_exists"] else "invalid"
|
||||
)
|
||||
output.write(
|
||||
f"{item['account_id']}\tcdp={item['cdp_port']}\tstate={state}"
|
||||
f"\tmembers={len(item['members'])}\n"
|
||||
)
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def _accounts_login_command(
|
||||
arguments: argparse.Namespace,
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
) -> int:
|
||||
from gyxx_flow.accounts import login_account
|
||||
|
||||
try:
|
||||
result = login_account(
|
||||
settings,
|
||||
arguments.account_id,
|
||||
output=output,
|
||||
timeout_seconds=arguments.timeout,
|
||||
target_url=arguments.target_url,
|
||||
force=arguments.force,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
raise CliRuntimeError(str(exc)) from exc
|
||||
if arguments.json:
|
||||
_write_json(output, result)
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def _accounts_sync_command(
|
||||
arguments: argparse.Namespace,
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
) -> int:
|
||||
from gyxx_flow.accounts import sync_account
|
||||
|
||||
try:
|
||||
result = sync_account(settings, arguments.account_id, output)
|
||||
except ValueError as exc:
|
||||
raise CliConfigurationError(str(exc)) from exc
|
||||
if arguments.json:
|
||||
_write_json(output, result)
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def _accounts_seed_command(
|
||||
arguments: argparse.Namespace,
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
) -> int:
|
||||
from gyxx_flow.accounts import seed_account
|
||||
|
||||
try:
|
||||
result = seed_account(
|
||||
settings,
|
||||
arguments.account_id,
|
||||
arguments.source_binding,
|
||||
output,
|
||||
force=arguments.force,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise CliConfigurationError(str(exc)) from exc
|
||||
if arguments.json:
|
||||
_write_json(output, result)
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def _console_command(
|
||||
arguments: argparse.Namespace,
|
||||
settings: Settings,
|
||||
@@ -355,6 +518,7 @@ def _console_command(
|
||||
host=arguments.host,
|
||||
port=arguments.port,
|
||||
output=output,
|
||||
env_files=arguments.env_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise CliRuntimeError("cannot start workflow console") from exc
|
||||
@@ -419,6 +583,7 @@ def _list_scripts(arguments: argparse.Namespace, output: TextIO) -> int:
|
||||
"entry": script.entry,
|
||||
"kind": script.kind,
|
||||
"default_args": list(script.default_args),
|
||||
"notification_workflow_id": script.notification_workflow_id,
|
||||
}
|
||||
for script in catalog.scripts
|
||||
if arguments.module is None or script.module == arguments.module
|
||||
@@ -457,6 +622,7 @@ def _run_script(
|
||||
trigger="manual",
|
||||
entry=script.entry,
|
||||
args=script_args,
|
||||
notification_workflow_id=script.notification_workflow_id,
|
||||
)
|
||||
workflow = WorkflowDefinition(
|
||||
workflow_id,
|
||||
@@ -521,16 +687,23 @@ def _python_scheduler(
|
||||
python_executable: Path | None = None,
|
||||
misfire_grace_seconds: int = 21600,
|
||||
dry_run: bool = False,
|
||||
enable_account_keepalive: bool = False,
|
||||
) -> PythonScheduler:
|
||||
launcher = SubprocessWorkflowLauncher(
|
||||
settings.project_root, settings.data_root, python_executable=python_executable
|
||||
)
|
||||
maintenance = None
|
||||
if enable_account_keepalive and not dry_run:
|
||||
from gyxx_flow.accounts import AccountKeepaliveManager
|
||||
|
||||
maintenance = AccountKeepaliveManager(settings).tick
|
||||
return PythonScheduler(
|
||||
catalog,
|
||||
settings.data_root,
|
||||
launcher=launcher,
|
||||
misfire_grace_seconds=misfire_grace_seconds,
|
||||
dry_run=dry_run,
|
||||
maintenance=maintenance,
|
||||
catalog_loader=lambda: WorkflowCatalog.load(
|
||||
settings.project_root / "config"
|
||||
),
|
||||
@@ -553,6 +726,7 @@ def _schedule_run_command(
|
||||
python_executable=arguments.python_executable,
|
||||
misfire_grace_seconds=arguments.misfire_grace_seconds,
|
||||
dry_run=arguments.dry_run,
|
||||
enable_account_keepalive=True,
|
||||
)
|
||||
lock_path = settings.data_root / "state" / "scheduler" / "service.lock"
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user