From 6200d8a957555cfe97a11604f134b2ab194ba469 Mon Sep 17 00:00:00 2001 From: RyanHuang Date: Wed, 30 Sep 2026 16:37:14 +0800 Subject: [PATCH 1/3] feat(hireaicreator): batch publication settings and resumable local videos --- contracts/command-catalog.json | 479 +++++++++++++++++- docs/agent-discoverability.md | 7 +- docs/domain-command-framework.md | 7 +- museoncli/domains/hireaicreator.py | 51 +- .../domains/hireaicreator_local_publish.py | 159 ++++++ pyproject.toml | 2 +- tests/fixtures/ai_hook_requests.json | 44 ++ tests/test_ai_hook_parameter_contract.py | 2 + tests/test_hireaicreator_local_publish.py | 148 ++++++ uv.lock | 2 +- 10 files changed, 888 insertions(+), 13 deletions(-) create mode 100644 museoncli/domains/hireaicreator_local_publish.py create mode 100644 tests/test_hireaicreator_local_publish.py diff --git a/contracts/command-catalog.json b/contracts/command-catalog.json index 2f5e862..d9b733c 100644 --- a/contracts/command-catalog.json +++ b/contracts/command-catalog.json @@ -3164,6 +3164,60 @@ } ], "hireaicreator": [ + { + "authorization": { + "authentication_required": true, + "enforcement": "museon_server", + "required_roles": [ + "workspace_member" + ], + "required_scopes": [ + "agent_cli.access" + ], + "resource_policy": "server_evaluated_workspace_and_resource_access", + "workspace_bound": true + }, + "capability_key": "hireaicreator.account-get-publish-settings", + "cli": "museoncli hireaicreator account +get-publish-settings", + "domain": "hireaicreator", + "execution": "direct", + "name": "hireaicreator.account-get-publish-settings", + "requires_confirmation": false, + "resource": "account", + "risk_level": "read", + "shortcut": "+get-publish-settings", + "stability": "stable", + "summary": "Read publication methods, global authorization, readiness and pending schedule counts for up to 200 workspace accounts in one request.", + "supports_dry_run": false, + "transport": "agent_cli_api" + }, + { + "authorization": { + "authentication_required": true, + "enforcement": "museon_server", + "required_roles": [ + "workspace_member" + ], + "required_scopes": [ + "agent_cli.access" + ], + "resource_policy": "server_evaluated_workspace_and_resource_access", + "workspace_bound": true + }, + "capability_key": "hireaicreator.account-set-publish-settings", + "cli": "museoncli hireaicreator account +set-publish-settings", + "domain": "hireaicreator", + "execution": "direct", + "name": "hireaicreator.account-set-publish-settings", + "requires_confirmation": false, + "resource": "account", + "risk_level": "write", + "shortcut": "+set-publish-settings", + "stability": "stable", + "summary": "Batch configure account publication. First read settings, send preview=true with exact updated_at values, then apply the same changes with preview=false. The server rejects stale settings and changes during active delivery. Supports different methods per account.", + "supports_dry_run": true, + "transport": "agent_cli_api" + }, { "authorization": { "authentication_required": true, @@ -4438,7 +4492,7 @@ "risk_level": "write", "shortcut": "+create-from-upload", "stability": "stable", - "summary": "Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok or Instagram, at most one account per platform per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries.", + "summary": "Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok, Instagram or YouTube; up to 100 accounts per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries.", "supports_dry_run": true, "transport": "agent_cli_api" }, @@ -4784,6 +4838,33 @@ "supports_dry_run": false, "transport": "agent_cli_api" }, + { + "authorization": { + "authentication_required": true, + "enforcement": "museon_server", + "required_roles": [ + "workspace_member" + ], + "required_scopes": [ + "agent_cli.access" + ], + "resource_policy": "server_evaluated_workspace_and_resource_access", + "workspace_bound": true + }, + "capability_key": "hireaicreator.video-publish-local", + "cli": "museoncli hireaicreator video +publish-local", + "domain": "hireaicreator", + "execution": "direct", + "name": "hireaicreator.video-publish-local", + "requires_confirmation": false, + "resource": "video", + "risk_level": "write", + "shortcut": "+publish-local", + "stability": "stable", + "summary": "Upload local finished videos and schedule each on explicit accounts. Each item has file, account_ids, caption, and either now=true or scheduled_at. Uses account publication settings; disabled/unready accounts fail before upload. Local receipts reuse uploads on retry; retain the idempotency key and files unchanged.", + "supports_dry_run": true, + "transport": "agent_cli_api" + }, { "authorization": { "authentication_required": true, @@ -9230,7 +9311,7 @@ "routines" ] }, - "contract_revision": "0.8.2", + "contract_revision": "0.9.0", "package": "museoncli", "schema_version": 2, "schemas": { @@ -19896,6 +19977,113 @@ "supports_dry_run": false, "transport": "agent_cli_api" }, + "hireaicreator.account-get-publish-settings": { + "authorization": { + "authentication_required": true, + "enforcement": "museon_server", + "required_roles": [ + "workspace_member" + ], + "required_scopes": [ + "agent_cli.access" + ], + "resource_policy": "server_evaluated_workspace_and_resource_access", + "workspace_bound": true + }, + "capability_key": "hireaicreator.account-get-publish-settings", + "cli": "museoncli hireaicreator account +get-publish-settings", + "domain": "hireaicreator", + "examples": [ + "museoncli hireaicreator account +get-publish-settings --account-ids ", + "museoncli schema hireaicreator.account-get-publish-settings" + ], + "execution": "direct", + "input_schema": { + "additionalProperties": false, + "properties": { + "account_ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "maxItems": 200, + "minItems": 1, + "type": "array" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "account_ids" + ], + "type": "object" + }, + "name": "hireaicreator.account-get-publish-settings", + "output_schema": { + "description": "Unmodified API resource or receipt. ", + "properties": { + "command": { + "type": "string" + }, + "data": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ] + }, + "next_steps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "run": { + "type": [ + "object", + "null" + ] + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspace": { + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "ok", + "command", + "data", + "run", + "warnings", + "next_steps" + ], + "type": "object" + }, + "requires_confirmation": false, + "resource": "account", + "risk_level": "read", + "shortcut": "+get-publish-settings", + "stability": "stable", + "summary": "Read publication methods, global authorization, readiness and pending schedule counts for up to 200 workspace accounts in one request.", + "supports_dry_run": false, + "transport": "agent_cli_api" + }, "hireaicreator.account-list": { "authorization": { "authentication_required": true, @@ -20329,6 +20517,141 @@ "supports_dry_run": true, "transport": "agent_cli_api" }, + "hireaicreator.account-set-publish-settings": { + "authorization": { + "authentication_required": true, + "enforcement": "museon_server", + "required_roles": [ + "workspace_member" + ], + "required_scopes": [ + "agent_cli.access" + ], + "resource_policy": "server_evaluated_workspace_and_resource_access", + "workspace_bound": true + }, + "capability_key": "hireaicreator.account-set-publish-settings", + "cli": "museoncli hireaicreator account +set-publish-settings", + "domain": "hireaicreator", + "examples": [ + "museoncli hireaicreator account +set-publish-settings --preview --args-file input.json", + "museoncli schema hireaicreator.account-set-publish-settings" + ], + "execution": "direct", + "input_schema": { + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "additionalProperties": false, + "properties": { + "account_id": { + "format": "uuid", + "type": "string" + }, + "expected_updated_at": { + "format": "date-time", + "type": "string" + }, + "method": { + "enum": [ + "api", + "rpa", + "tiktok_draft", + "disabled" + ], + "type": "string" + } + }, + "required": [ + "account_id", + "method", + "expected_updated_at" + ], + "type": "object" + }, + "maxItems": 200, + "minItems": 1, + "type": "array" + }, + "preview": { + "type": "boolean" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "changes", + "preview" + ], + "type": "object" + }, + "name": "hireaicreator.account-set-publish-settings", + "output_schema": { + "description": "Unmodified API resource or receipt. Read returned account methods; all changes are atomic. No publication is triggered by this command.", + "properties": { + "command": { + "type": "string" + }, + "data": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ] + }, + "next_steps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "run": { + "type": [ + "object", + "null" + ] + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspace": { + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "ok", + "command", + "data", + "run", + "warnings", + "next_steps" + ], + "type": "object" + }, + "requires_confirmation": false, + "resource": "account", + "risk_level": "write", + "shortcut": "+set-publish-settings", + "stability": "stable", + "summary": "Batch configure account publication. First read settings, send preview=true with exact updated_at values, then apply the same changes with preview=false. The server rejects stale settings and changes during active delivery. Supports different methods per account.", + "supports_dry_run": true, + "transport": "agent_cli_api" + }, "hireaicreator.actor-assign": { "authorization": { "authentication_required": true, @@ -32190,7 +32513,7 @@ "minLength": 1, "type": "string" }, - "maxItems": 2, + "maxItems": 100, "minItems": 1, "type": "array" }, @@ -32306,7 +32629,7 @@ "risk_level": "write", "shortcut": "+create-from-upload", "stability": "stable", - "summary": "Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok or Instagram, at most one account per platform per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries.", + "summary": "Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok, Instagram or YouTube; up to 100 accounts per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries.", "supports_dry_run": true, "transport": "agent_cli_api" }, @@ -33315,6 +33638,154 @@ "supports_dry_run": false, "transport": "agent_cli_api" }, + "hireaicreator.video-publish-local": { + "authorization": { + "authentication_required": true, + "enforcement": "museon_server", + "required_roles": [ + "workspace_member" + ], + "required_scopes": [ + "agent_cli.access" + ], + "resource_policy": "server_evaluated_workspace_and_resource_access", + "workspace_bound": true + }, + "capability_key": "hireaicreator.video-publish-local", + "cli": "museoncli hireaicreator video +publish-local", + "domain": "hireaicreator", + "examples": [ + "museoncli hireaicreator video +publish-local --idempotency-key --args-file input.json", + "museoncli schema hireaicreator.video-publish-local" + ], + "execution": "direct", + "input_schema": { + "additionalProperties": false, + "properties": { + "idempotency_key": { + "maxLength": 200, + "minLength": 8, + "type": "string" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "account_ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "type": "array" + }, + "caption": { + "minLength": 1, + "type": "string" + }, + "file": { + "minLength": 1, + "type": "string" + }, + "now": { + "type": "boolean" + }, + "schedule_timezone": { + "format": "timezone", + "type": "string" + }, + "scheduled_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "file", + "account_ids", + "caption" + ], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "items", + "idempotency_key" + ], + "type": "object" + }, + "name": "hireaicreator.video-publish-local", + "output_schema": { + "description": "Unmodified API resource or receipt. Use hireaicreator video +get for returned video IDs; drafts are not public posts.", + "properties": { + "command": { + "type": "string" + }, + "data": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ] + }, + "next_steps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "run": { + "type": [ + "object", + "null" + ] + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspace": { + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "ok", + "command", + "data", + "run", + "warnings", + "next_steps" + ], + "type": "object" + }, + "requires_confirmation": false, + "resource": "video", + "risk_level": "write", + "shortcut": "+publish-local", + "stability": "stable", + "summary": "Upload local finished videos and schedule each on explicit accounts. Each item has file, account_ids, caption, and either now=true or scheduled_at. Uses account publication settings; disabled/unready accounts fail before upload. Local receipts reuse uploads on retry; retain the idempotency key and files unchanged.", + "supports_dry_run": true, + "transport": "agent_cli_api" + }, "hireaicreator.video-regenerate-ai-hook": { "authorization": { "authentication_required": true, diff --git a/docs/agent-discoverability.md b/docs/agent-discoverability.md index 9cc91e5..e9e11a4 100644 --- a/docs/agent-discoverability.md +++ b/docs/agent-discoverability.md @@ -20,7 +20,7 @@ after changing specs; `tests/test_docs_sync.py` fails CI on drift. -239 commands across 11 domains (source of truth: `museoncli schema`). +242 commands across 11 domains (source of truth: `museoncli schema`). ### research @@ -90,6 +90,8 @@ after changing specs; `tests/test_docs_sync.py` fails CI on drift. | command | risk | dry-run | confirm | execution | summary | |---|---|---|---|---|---| +| `hireaicreator account +get-publish-settings` | read | — | — | direct | Read publication methods, global authorization, readiness and pending schedule counts for up to 200 workspace accounts in one request. | +| `hireaicreator account +set-publish-settings` | write | yes | — | direct | Batch configure account publication. First read settings, send preview=true with exact updated_at values, then apply the same changes with preview=false. The server rejects stale settings and changes during active delivery. Supports different methods per account. | | `hireaicreator account +list` | read | — | — | direct | Find exact account identities, workspace tags, and current stage/assignment state; --tags matches any listed workspace tag; complete pagination before fixing a selection. | | `hireaicreator account +get-assets` | read | — | — | direct | Read an account's Actor, Persona and other publish asset bindings. | | `hireaicreator account +set-actor` | write | yes | — | direct | Bind an existing Actor to an account; use its exact ID to resolve duplicate names. | @@ -133,7 +135,7 @@ after changing specs; `tests/test_docs_sync.py` fails CI on drift. | `hireaicreator video +update` | write | yes | — | direct | Patch only supplied video fields using the observed expected_version; do not auto-retry conflicts. | | `hireaicreator video +generate` | write | yes | — | direct | Request video generation with a stable idempotency key; response is acceptance only. | | `hireaicreator video +bulk-schedule` | write | yes | — | direct | Schedule explicit video/version/account/time tuples; retain succeeded/conflicted/failures. | -| `hireaicreator video +create-from-upload` | write | yes | — | direct | Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok or Instagram, at most one account per platform per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries. | +| `hireaicreator video +create-from-upload` | write | yes | — | direct | Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok, Instagram or YouTube; up to 100 accounts per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries. | | `hireaicreator plan +preview` | read | — | — | direct | Read server allocation and blockers for a proposed plan; does not create the plan. | | `hireaicreator plan +get-capacity` | read | — | — | direct | Read account capacity for a bounded date range. | | `hireaicreator plan +create` | write | yes | — | direct | Create a plan with a stable idempotency key. Server defaults start_generation to false; creation is not a finished video. | @@ -146,6 +148,7 @@ after changing specs; `tests/test_docs_sync.py` fails CI on drift. | `hireaicreator delivery +get` | read | — | — | direct | Read a public collection by its opaque token. Paginate with total/items; no has_more is promised. | | `hireaicreator delivery +export` | write | yes | — | direct | Request an export bound to video version; preserve the idempotency key on retry. | | `hireaicreator delivery +get-export` | read | — | — | direct | Read actual export status, version/revision, error and download URL. | +| `hireaicreator video +publish-local` | write | yes | — | direct | Upload local finished videos and schedule each on explicit accounts. Each item has file, account_ids, caption, and either now=true or scheduled_at. Uses account publication settings; disabled/unready accounts fail before upload. Local receipts reuse uploads on retry; retain the idempotency key and files unchanged. | | `hireaicreator test-plan +ensure` | write | yes | — | direct | Get or create the workspace Test Plan. | | `hireaicreator test-plan +update` | write | yes | — | direct | Update workspace Test Plan defaults. | | `hireaicreator content-group +list` | read | — | — | direct | List reusable content groups; complete pagination. | diff --git a/docs/domain-command-framework.md b/docs/domain-command-framework.md index 374383b..bf96207 100644 --- a/docs/domain-command-framework.md +++ b/docs/domain-command-framework.md @@ -36,7 +36,7 @@ The tables below are generated — edit code, then run -239 commands across 11 domains (source of truth: `museoncli schema`). +242 commands across 11 domains (source of truth: `museoncli schema`). ### research @@ -106,6 +106,8 @@ The tables below are generated — edit code, then run | command | risk | dry-run | confirm | execution | summary | |---|---|---|---|---|---| +| `hireaicreator account +get-publish-settings` | read | — | — | direct | Read publication methods, global authorization, readiness and pending schedule counts for up to 200 workspace accounts in one request. | +| `hireaicreator account +set-publish-settings` | write | yes | — | direct | Batch configure account publication. First read settings, send preview=true with exact updated_at values, then apply the same changes with preview=false. The server rejects stale settings and changes during active delivery. Supports different methods per account. | | `hireaicreator account +list` | read | — | — | direct | Find exact account identities, workspace tags, and current stage/assignment state; --tags matches any listed workspace tag; complete pagination before fixing a selection. | | `hireaicreator account +get-assets` | read | — | — | direct | Read an account's Actor, Persona and other publish asset bindings. | | `hireaicreator account +set-actor` | write | yes | — | direct | Bind an existing Actor to an account; use its exact ID to resolve duplicate names. | @@ -149,7 +151,7 @@ The tables below are generated — edit code, then run | `hireaicreator video +update` | write | yes | — | direct | Patch only supplied video fields using the observed expected_version; do not auto-retry conflicts. | | `hireaicreator video +generate` | write | yes | — | direct | Request video generation with a stable idempotency key; response is acceptance only. | | `hireaicreator video +bulk-schedule` | write | yes | — | direct | Schedule explicit video/version/account/time tuples; retain succeeded/conflicted/failures. | -| `hireaicreator video +create-from-upload` | write | yes | — | direct | Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok or Instagram, at most one account per platform per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries. | +| `hireaicreator video +create-from-upload` | write | yes | — | direct | Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok, Instagram or YouTube; up to 100 accounts per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries. | | `hireaicreator plan +preview` | read | — | — | direct | Read server allocation and blockers for a proposed plan; does not create the plan. | | `hireaicreator plan +get-capacity` | read | — | — | direct | Read account capacity for a bounded date range. | | `hireaicreator plan +create` | write | yes | — | direct | Create a plan with a stable idempotency key. Server defaults start_generation to false; creation is not a finished video. | @@ -162,6 +164,7 @@ The tables below are generated — edit code, then run | `hireaicreator delivery +get` | read | — | — | direct | Read a public collection by its opaque token. Paginate with total/items; no has_more is promised. | | `hireaicreator delivery +export` | write | yes | — | direct | Request an export bound to video version; preserve the idempotency key on retry. | | `hireaicreator delivery +get-export` | read | — | — | direct | Read actual export status, version/revision, error and download URL. | +| `hireaicreator video +publish-local` | write | yes | — | direct | Upload local finished videos and schedule each on explicit accounts. Each item has file, account_ids, caption, and either now=true or scheduled_at. Uses account publication settings; disabled/unready accounts fail before upload. Local receipts reuse uploads on retry; retain the idempotency key and files unchanged. | | `hireaicreator test-plan +ensure` | write | yes | — | direct | Get or create the workspace Test Plan. | | `hireaicreator test-plan +update` | write | yes | — | direct | Update workspace Test Plan defaults. | | `hireaicreator content-group +list` | read | — | — | direct | List reusable content groups; complete pagination. | diff --git a/museoncli/domains/hireaicreator.py b/museoncli/domains/hireaicreator.py index 591e8d3..883638a 100644 --- a/museoncli/domains/hireaicreator.py +++ b/museoncli/domains/hireaicreator.py @@ -164,7 +164,7 @@ def enum(*values: str) -> dict[str, Any]: UPLOADED_VIDEO_ITEM = obj( { "media_id": U, - "publishing_account_ids": arr(S, 1, 2), + "publishing_account_ids": arr(S, 1, 100), "caption": {"type": "string", "minLength": 1, "maxLength": 2200}, "scheduled_at": nullable(DT), "schedule_timezone": TZ, @@ -564,6 +564,42 @@ async def execute(ctx: CommandContext) -> dict[str, Any]: def specs() -> list[CommandSpec]: commands = [ + _command( + "account", + "get-publish-settings", + "POST", + "/pool-accounts/publication-settings:read", + {"account_ids": arr(U, 1, 200)}, + required=("account_ids",), + workspace="body", + summary="Read publication methods, global authorization, readiness and pending schedule counts for up to 200 workspace accounts in one request.", + ), + _command( + "account", + "set-publish-settings", + "POST", + "/pool-accounts/publication-settings:batch-update", + { + "changes": arr( + obj( + { + "account_id": U, + "method": enum("api", "rpa", "tiktok_draft", "disabled"), + "expected_updated_at": DT, + }, + ("account_id", "method", "expected_updated_at"), + ), + 1, + 200, + ), + "preview": B, + }, + required=("changes", "preview"), + workspace="body", + write=True, + summary="Batch configure account publication. First read settings, send preview=true with exact updated_at values, then apply the same changes with preview=false. The server rejects stale settings and changes during active delivery. Supports different methods per account.", + readback="Read returned account methods; all changes are atomic. No publication is triggered by this command.", + ), _command( "account", "list", @@ -1180,7 +1216,7 @@ def specs() -> list[CommandSpec]: workspace="body", write=True, idempotent=True, - summary="Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok or Instagram, at most one account per platform per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries.", + summary="Register finished videos uploaded with media +upload as publishing tasks, one per account: TikTok, Instagram or YouTube; up to 100 accounts per video. They need no POV, overlay or render and publish like any video on the account. Keep the idempotency key on retries.", readback="Read each returned video ID with hireaicreator video +get; change caption or time with video +update or video +bulk-schedule.", ), _command( @@ -1365,4 +1401,13 @@ def specs() -> list[CommandSpec]: from .hireaicreator_formats import specs as format_specs - return commands + test_group_specs() + warmup_specs() + video_specs() + format_specs() + from .hireaicreator_local_publish import specs as local_publish_specs + + return ( + commands + + local_publish_specs() + + test_group_specs() + + warmup_specs() + + video_specs() + + format_specs() + ) diff --git a/museoncli/domains/hireaicreator_local_publish.py b/museoncli/domains/hireaicreator_local_publish.py new file mode 100644 index 0000000..6bc071d --- /dev/null +++ b/museoncli/domains/hireaicreator_local_publish.py @@ -0,0 +1,159 @@ +"""Upload local finished videos and register durable account publication tasks.""" + +from __future__ import annotations + +import hashlib +import json +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from museoncli.domains.hireaicreator import B, DT, S, TZ, U, EXECUTORS, _command, arr, obj +from museoncli.envelopes import direct_api_envelope +from museoncli.execution import CommandContext + + +def _digest_file(path: Path) -> str: + with path.open("rb") as handle: + return hashlib.file_digest(handle, "sha256").hexdigest() + + +def _save(path: Path, record: dict[str, Any]) -> None: + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(record, ensure_ascii=False)) + temporary.replace(path) + + +async def _publish(ctx: CommandContext) -> dict[str, Any]: + if not ctx.workspace_id: + raise RuntimeError("missing_workspace") + args = ctx.arguments + items = args["items"] + account_ids = sorted({value for item in items for value in item["account_ids"]}) + if len(account_ids) > 200: + raise ValueError("Select at most 200 accounts per batch") + fingerprint_items = [] + for item in items: + path = Path(item["file"]).expanduser().resolve(strict=True) + if not path.is_file(): + raise ValueError(f"Not a file: {path}") + if bool(item.get("now")) == bool(item.get("scheduled_at")): + raise ValueError("Each item requires exactly one of now=true or scheduled_at") + fingerprint_items.append({**item, "file": str(path), "sha256": _digest_file(path)}) + fingerprint = hashlib.sha256(json.dumps(fingerprint_items, sort_keys=True).encode()).hexdigest() + identity = json.dumps([ctx.cfg.api_base_url, ctx.workspace_id, args["idempotency_key"]]) + key = hashlib.sha256(identity.encode()).hexdigest() + receipt_dir = ( + Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "museoncli" / "publication" + ) + receipt_dir.mkdir(parents=True, exist_ok=True) + receipt_path = receipt_dir / f"{key}.json" + lock_path = receipt_dir / f"{key}.lock" + try: + lock_fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as exc: + raise RuntimeError( + f"Publication is already running, or interrupted; inspect {lock_path} before retrying" + ) from exc + os.close(lock_fd) + try: + record = ( + json.loads(receipt_path.read_text()) + if receipt_path.exists() + else {"fingerprint": fingerprint, "uploaded": {}, "scheduled_now": None} + ) + if record["fingerprint"] != fingerprint: + raise ValueError("Idempotency key was already used for different files or targets") + if "receipt" in record: + return record["receipt"] + settings = await ctx.api_data_v2( + ctx.cfg, + "POST", + "/pool-accounts/publication-settings:read", + json_body={"workspace_id": ctx.workspace_id, "account_ids": account_ids}, + unwrap_success=True, + ) + blocked = [row["id"] for row in settings["items"] if not row["ready"]] + if blocked: + raise ValueError("Accounts are not ready for publication: " + ", ".join(blocked)) + _save(receipt_path, record) + payload_items = [] + for item in fingerprint_items: + digest = item["sha256"] + if digest not in record["uploaded"]: + upload = await ctx.upload_media_file( + ctx.cfg, + workspace_id=ctx.workspace_id, + arguments={"file": item["file"], "media_type": "video"}, + ) + record["uploaded"][digest] = upload["data"]["asset"]["media_id"] + _save(receipt_path, record) + payload_items.append( + { + "media_id": record["uploaded"][digest], + "publishing_account_ids": item["account_ids"], + "caption": item["caption"], + "scheduled_at": item.get("scheduled_at") or "now", + "schedule_timezone": item.get("schedule_timezone", "UTC"), + } + ) + if record["scheduled_now"] is None: + record["scheduled_now"] = (datetime.now(UTC) + timedelta(seconds=30)).isoformat() + _save(receipt_path, record) + for item in payload_items: + if item["scheduled_at"] == "now": + item["scheduled_at"] = record["scheduled_now"] + raw = await ctx.api_data_v2( + ctx.cfg, + "POST", + "/ai-hook-videos/from-upload", + json_body={"workspace_id": ctx.workspace_id, "items": payload_items}, + idempotency_key=args["idempotency_key"], + unwrap_success=True, + ) + receipt = direct_api_envelope( + ctx.spec.schema_name, ctx.workspace_id, raw, site_url=ctx.cfg.site_url + ) + receipt["warnings"].append( + "Accepted publication tasks, not proof of public posts. Read each video for final delivery state." + ) + record["receipt"] = receipt + _save(receipt_path, record) + return receipt + finally: + lock_path.unlink(missing_ok=True) + + +def specs(): + spec = _command( + "video", + "publish-local", + "POST", + "/ai-hook-videos/from-upload", + { + "items": arr( + obj( + { + "file": S, + "account_ids": arr(U, 1, 100), + "caption": S, + "scheduled_at": DT, + "now": B, + "schedule_timezone": TZ, + }, + ("file", "account_ids", "caption"), + ), + 1, + 50, + ), + }, + required=("items",), + workspace="body", + write=True, + idempotent=True, + summary="Upload local finished videos and schedule each on explicit accounts. Each item has file, account_ids, caption, and either now=true or scheduled_at. Uses account publication settings; disabled/unready accounts fail before upload. Local receipts reuse uploads on retry; retain the idempotency key and files unchanged.", + readback="Use hireaicreator video +get for returned video IDs; drafts are not public posts.", + ) + EXECUTORS[spec.schema_name] = _publish + return [spec] diff --git a/pyproject.toml b/pyproject.toml index a2b4cf6..e2ae3d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "museoncli" -version = "0.8.2" +version = "0.9.0" description = "Agent-ready CLI for Museon's social media operating platform" readme = "README.md" license = "Apache-2.0" diff --git a/tests/fixtures/ai_hook_requests.json b/tests/fixtures/ai_hook_requests.json index 8168236..a553d07 100644 --- a/tests/fixtures/ai_hook_requests.json +++ b/tests/fixtures/ai_hook_requests.json @@ -1522,5 +1522,49 @@ "header": { "Idempotency-Key": "external-upload-batch-001" } + }, + { + "name": "account-get-publish-settings", + "method": "POST", + "path": "/api/v2/pool-accounts/publication-settings:read", + "input": { + "workspace_id": "40000000-0000-4000-8000-000000000004", + "account_ids": [ + "50000000-0000-4000-8000-000000000005" + ] + }, + "body": { + "workspace_id": "40000000-0000-4000-8000-000000000004", + "account_ids": [ + "50000000-0000-4000-8000-000000000005" + ] + } + }, + { + "name": "account-set-publish-settings", + "method": "POST", + "path": "/api/v2/pool-accounts/publication-settings:batch-update", + "input": { + "workspace_id": "40000000-0000-4000-8000-000000000004", + "preview": true, + "changes": [ + { + "account_id": "50000000-0000-4000-8000-000000000005", + "method": "api", + "expected_updated_at": "2026-09-30T00:00:00Z" + } + ] + }, + "body": { + "workspace_id": "40000000-0000-4000-8000-000000000004", + "preview": true, + "changes": [ + { + "account_id": "50000000-0000-4000-8000-000000000005", + "method": "api", + "expected_updated_at": "2026-09-30T00:00:00Z" + } + ] + } } ] diff --git a/tests/test_ai_hook_parameter_contract.py b/tests/test_ai_hook_parameter_contract.py index 4f084ef..d0f80d3 100644 --- a/tests/test_ai_hook_parameter_contract.py +++ b/tests/test_ai_hook_parameter_contract.py @@ -161,7 +161,9 @@ def test_every_declared_field_and_flag_has_an_independent_request_case(): spec.schema_name.removeprefix("hireaicreator."): spec for spec in command_specs() if spec.domain.value == "hireaicreator" + and spec.schema_name != "hireaicreator.video-publish-local" } + # Local upload has a multi-request, resumable workflow, covered separately. assert set(specs) == {case["name"] for case in CASES} for case in CASES: spec = specs[case["name"]] diff --git a/tests/test_hireaicreator_local_publish.py b/tests/test_hireaicreator_local_publish.py new file mode 100644 index 0000000..a5872ca --- /dev/null +++ b/tests/test_hireaicreator_local_publish.py @@ -0,0 +1,148 @@ +"""Local video delivery must resume uploads without duplicating publication tasks.""" + +import asyncio +import json +from datetime import datetime + +import httpx +import pytest + +import museoncli.main as cli +from museoncli.config import AuthState, Config, WorkspaceState + +WS = "40000000-0000-4000-8000-000000000004" +ACCOUNT = "50000000-0000-4000-8000-000000000005" +MEDIA = "60000000-0000-4000-8000-000000000006" +HTTP_CLIENT = httpx.AsyncClient + + +def invoke(arguments): + return asyncio.run( + cli.dispatch( + cli.build_parser().parse_args( + ["hireaicreator", "video", "+publish-local", "--args-json", json.dumps(arguments)] + ) + ) + ) + + +def setup(monkeypatch, tmp_path, handler): + cfg = Config( + api_base_url="https://api.example.test/api/v1", + workspace=WorkspaceState(id=WS), + auth=AuthState(api_key="test-only"), + ) + monkeypatch.setattr(cli, "load_config", lambda: cfg) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.setattr( + cli.httpx, + "AsyncClient", + lambda **kwargs: HTTP_CLIENT(**kwargs, transport=httpx.MockTransport(handler)), + ) + video = tmp_path / "finished.mp4" + video.write_bytes(b"local-video-fixture") + return { + "workspace_id": WS, + "idempotency_key": "finished-video-batch-1", + "items": [ + { + "file": str(video), + "account_ids": [ACCOUNT], + "caption": "Finished local video", + "now": True, + "schedule_timezone": "Asia/Shanghai", + } + ], + } + + +def test_unknown_submission_outcome_reuses_upload_and_exact_body(monkeypatch, tmp_path): + uploads = [] + submissions = [] + + def handler(request): + if request.url.path.endswith("publication-settings:read"): + assert json.loads(request.content) == {"workspace_id": WS, "account_ids": [ACCOUNT]} + return httpx.Response(200, json={"items": [{"id": ACCOUNT, "ready": True}]}) + if request.url.path.endswith("/media/upload"): + uploads.append(request) + assert b"local-video-fixture" in request.content + return httpx.Response( + 200, json={"success": True, "data": {"asset": {"media_id": MEDIA}}} + ) + assert request.url.path == "/api/v2/ai-hook-videos/from-upload" + assert request.headers["Idempotency-Key"] == "finished-video-batch-1" + submissions.append(json.loads(request.content)) + if len(submissions) == 1: + return httpx.Response(409, json={"detail": "temporarily unavailable"}) + return httpx.Response(200, json={"items": [{"id": "video-1", "status": "scheduled"}]}) + + args = setup(monkeypatch, tmp_path, handler) + with pytest.raises(Exception): + invoke(args) + result = invoke(args) + assert len(uploads) == 1 and len(submissions) == 2 + assert submissions[0] == submissions[1] + item = submissions[1]["items"][0] + assert item["media_id"] == MEDIA and item["publishing_account_ids"] == [ACCOUNT] + assert ( + item["caption"] == "Finished local video" and item["schedule_timezone"] == "Asia/Shanghai" + ) + assert datetime.fromisoformat(item["scheduled_at"]).tzinfo is not None + assert result["data"]["items"][0]["status"] == "scheduled" + assert invoke(args) == result + assert len(submissions) == 2 + args["items"][0]["caption"] = "Different intent" + with pytest.raises(ValueError, match="Idempotency"): + invoke(args) + + +def test_unready_account_never_uploads_or_submits(monkeypatch, tmp_path): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json={"items": [{"id": ACCOUNT, "ready": False}]}) + + args = setup(monkeypatch, tmp_path, handler) + with pytest.raises(ValueError, match="not ready"): + invoke(args) + assert len(requests) == 1 and requests[0].url.path.endswith("publication-settings:read") + + +def test_explicit_schedule_and_dry_run_do_not_change_publication_semantics(monkeypatch, tmp_path): + calls = [] + + def handler(request): + calls.append(request) + if request.url.path.endswith("publication-settings:read"): + return httpx.Response(200, json={"items": [{"id": ACCOUNT, "ready": True}]}) + if request.url.path.endswith("/media/upload"): + return httpx.Response( + 200, json={"success": True, "data": {"asset": {"media_id": MEDIA}}} + ) + assert ( + json.loads(request.content)["items"][0]["scheduled_at"] == "2030-10-01T18:00:00+08:00" + ) + return httpx.Response(200, json={"items": []}) + + args = setup(monkeypatch, tmp_path, handler) + args["items"][0].pop("now") + args["items"][0]["scheduled_at"] = "2030-10-01T18:00:00+08:00" + asyncio.run( + cli.dispatch( + cli.build_parser().parse_args( + [ + "hireaicreator", + "video", + "+publish-local", + "--args-json", + json.dumps(args), + "--dry-run", + ] + ) + ) + ) + assert not calls + invoke(args) + assert len(calls) == 3 diff --git a/uv.lock b/uv.lock index 83370df..a91f2ba 100644 --- a/uv.lock +++ b/uv.lock @@ -330,7 +330,7 @@ wheels = [ [[package]] name = "museoncli" -version = "0.8.2" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "anyio" }, From e262d6633dfcd0eae28fc39fb28fff00d1508a5a Mon Sep 17 00:00:00 2001 From: RyanHuang Date: Wed, 30 Sep 2026 16:38:55 +0800 Subject: [PATCH 2/3] chore(release): align official installation links with 0.9.0 --- README.md | 2 +- README.zh-CN.md | 2 +- docs/install.md | 2 +- skills/museon-content-workflow-base/SKILL.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5eaab44..fee0a2e 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ If you prefer to install the CLI yourself, use Python 3.11+, `uv`, and the exact wheel from the official GitHub release: ```bash -uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.8.2/museoncli-0.8.2-py3-none-any.whl" +uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.9.0/museoncli-0.9.0-py3-none-any.whl" ``` Then continue with Skill setup and browser authorization: diff --git a/README.zh-CN.md b/README.zh-CN.md index d458352..b743356 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -85,7 +85,7 @@ https://www.museon.ai/cli/install.md 固定版本的 wheel: ```bash -uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.8.2/museoncli-0.8.2-py3-none-any.whl" +uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.9.0/museoncli-0.9.0-py3-none-any.whl" ``` 安装成功后继续配置 Skills 和浏览器授权: diff --git a/docs/install.md b/docs/install.md index f5fd171..00e58fb 100644 --- a/docs/install.md +++ b/docs/install.md @@ -41,7 +41,7 @@ If the command is unavailable: reviewed wheel from the official GitHub release: ```bash - uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.8.2/museoncli-0.8.2-py3-none-any.whl" + uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.9.0/museoncli-0.9.0-py3-none-any.whl" ``` Do not clone the repository, install from a mutable branch, or substitute a diff --git a/skills/museon-content-workflow-base/SKILL.md b/skills/museon-content-workflow-base/SKILL.md index 5165d6c..06345e6 100644 --- a/skills/museon-content-workflow-base/SKILL.md +++ b/skills/museon-content-workflow-base/SKILL.md @@ -46,7 +46,7 @@ verify `museoncli version`, then resume the original task: ```bash -uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.8.2/museoncli-0.8.2-py3-none-any.whl" +uv tool install "https://github.com/Museon-AI/museon-cli/releases/download/v0.9.0/museoncli-0.9.0-py3-none-any.whl" ``` Read [operating-model.md](references/operating-model.md) for risk, async work, authentication, From e1db558947aa93314ed7f232eb34d66e4166a3eb Mon Sep 17 00:00:00 2001 From: RyanHuang Date: Wed, 30 Sep 2026 17:18:29 +0800 Subject: [PATCH 3/3] fix(cli): honor explicit workspace for local video publication --- .../domains/hireaicreator_local_publish.py | 15 ++++++++------- tests/test_hireaicreator_local_publish.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/museoncli/domains/hireaicreator_local_publish.py b/museoncli/domains/hireaicreator_local_publish.py index 6bc071d..61454e8 100644 --- a/museoncli/domains/hireaicreator_local_publish.py +++ b/museoncli/domains/hireaicreator_local_publish.py @@ -26,9 +26,10 @@ def _save(path: Path, record: dict[str, Any]) -> None: async def _publish(ctx: CommandContext) -> dict[str, Any]: - if not ctx.workspace_id: - raise RuntimeError("missing_workspace") args = ctx.arguments + workspace_id = str(args.get("workspace_id") or ctx.workspace_id or "").strip() + if not workspace_id: + raise RuntimeError("missing_workspace") items = args["items"] account_ids = sorted({value for item in items for value in item["account_ids"]}) if len(account_ids) > 200: @@ -42,7 +43,7 @@ async def _publish(ctx: CommandContext) -> dict[str, Any]: raise ValueError("Each item requires exactly one of now=true or scheduled_at") fingerprint_items.append({**item, "file": str(path), "sha256": _digest_file(path)}) fingerprint = hashlib.sha256(json.dumps(fingerprint_items, sort_keys=True).encode()).hexdigest() - identity = json.dumps([ctx.cfg.api_base_url, ctx.workspace_id, args["idempotency_key"]]) + identity = json.dumps([ctx.cfg.api_base_url, workspace_id, args["idempotency_key"]]) key = hashlib.sha256(identity.encode()).hexdigest() receipt_dir = ( Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "museoncli" / "publication" @@ -71,7 +72,7 @@ async def _publish(ctx: CommandContext) -> dict[str, Any]: ctx.cfg, "POST", "/pool-accounts/publication-settings:read", - json_body={"workspace_id": ctx.workspace_id, "account_ids": account_ids}, + json_body={"workspace_id": workspace_id, "account_ids": account_ids}, unwrap_success=True, ) blocked = [row["id"] for row in settings["items"] if not row["ready"]] @@ -84,7 +85,7 @@ async def _publish(ctx: CommandContext) -> dict[str, Any]: if digest not in record["uploaded"]: upload = await ctx.upload_media_file( ctx.cfg, - workspace_id=ctx.workspace_id, + workspace_id=workspace_id, arguments={"file": item["file"], "media_type": "video"}, ) record["uploaded"][digest] = upload["data"]["asset"]["media_id"] @@ -108,12 +109,12 @@ async def _publish(ctx: CommandContext) -> dict[str, Any]: ctx.cfg, "POST", "/ai-hook-videos/from-upload", - json_body={"workspace_id": ctx.workspace_id, "items": payload_items}, + json_body={"workspace_id": workspace_id, "items": payload_items}, idempotency_key=args["idempotency_key"], unwrap_success=True, ) receipt = direct_api_envelope( - ctx.spec.schema_name, ctx.workspace_id, raw, site_url=ctx.cfg.site_url + ctx.spec.schema_name, workspace_id, raw, site_url=ctx.cfg.site_url ) receipt["warnings"].append( "Accepted publication tasks, not proof of public posts. Read each video for final delivery state." diff --git a/tests/test_hireaicreator_local_publish.py b/tests/test_hireaicreator_local_publish.py index a5872ca..9c95604 100644 --- a/tests/test_hireaicreator_local_publish.py +++ b/tests/test_hireaicreator_local_publish.py @@ -146,3 +146,20 @@ def handler(request): assert not calls invoke(args) assert len(calls) == 3 + + +def test_explicit_workspace_overrides_selected_workspace(monkeypatch, tmp_path): + requested = "70000000-0000-4000-8000-000000000007" + seen = [] + + def handler(request): + seen.append(request) + assert request.url.path.endswith("publication-settings:read") + assert json.loads(request.content)["workspace_id"] == requested + return httpx.Response(200, json={"items": [{"id": ACCOUNT, "ready": False}]}) + + args = setup(monkeypatch, tmp_path, handler) + args["workspace_id"] = requested + with pytest.raises(ValueError, match="not ready"): + invoke(args) + assert len(seen) == 1