socialcli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of socialcli might be problematic. Click here for more details.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- socialctl/youtube_data_service.py +225 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Validate native peer data before exposing approval or operation evidence."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from .approval import SCHEMA, hash_value, validate_payload
|
|
5
|
+
from .models import NativeJob, NativeObservation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_data(rows, *, command, brand, jobs, request_args):
|
|
9
|
+
by_id = {job['id']: job for job in jobs}
|
|
10
|
+
specific = {'approve', 'dispatch', 'reconcile', 'handoff', 'submit-marker', 'cancel', 'reschedule'}
|
|
11
|
+
expected = request_args[1] if command in specific and len(request_args) > 1 else None
|
|
12
|
+
|
|
13
|
+
def shape(value, required, optional=()):
|
|
14
|
+
if not isinstance(value, dict) or not set(required) <= set(value) or set(value) - set(required) - set(optional):
|
|
15
|
+
raise ValueError('Invalid native data shape')
|
|
16
|
+
|
|
17
|
+
def job(value):
|
|
18
|
+
parsed = NativeJob.model_validate_json(value if isinstance(value, str) else json.dumps(value))
|
|
19
|
+
if parsed.brand != brand or expected is not None and parsed.id != expected:
|
|
20
|
+
raise ValueError('Mismatched native data identity')
|
|
21
|
+
return parsed
|
|
22
|
+
|
|
23
|
+
def target(identifier):
|
|
24
|
+
if expected is not None and identifier != expected or identifier not in by_id:
|
|
25
|
+
raise ValueError('Unknown native result target')
|
|
26
|
+
return by_id[identifier]
|
|
27
|
+
|
|
28
|
+
def first_comment(value, known):
|
|
29
|
+
shape(value, ('status',), ('publication_id','change_id','comment_id'))
|
|
30
|
+
if value['status'] == 'not_requested':
|
|
31
|
+
if set(value) != {'status'}:
|
|
32
|
+
raise ValueError('Invalid unrequested first-comment result')
|
|
33
|
+
return
|
|
34
|
+
statuses = {'pending','manual_review','observed_existing','proposed','applying','uncertain',
|
|
35
|
+
'rejected','verified','failed','conflict','partial','accepted_unverifiable'}
|
|
36
|
+
if value['status'] not in statuses:
|
|
37
|
+
raise ValueError('Unknown native first-comment state')
|
|
38
|
+
from uuid import UUID
|
|
39
|
+
try:
|
|
40
|
+
UUID(value['publication_id'])
|
|
41
|
+
except (KeyError, ValueError, TypeError, AttributeError):
|
|
42
|
+
raise ValueError('Invalid native publication recovery ID') from None
|
|
43
|
+
for key in ('change_id', 'comment_id'):
|
|
44
|
+
if value.get(key) is not None and (not isinstance(value[key],str) or not value[key].strip()):
|
|
45
|
+
raise ValueError('Invalid native comment identity')
|
|
46
|
+
if value['status'] == 'verified' and (known['state'] != 'published'
|
|
47
|
+
or not value.get('change_id') or not value.get('comment_id')):
|
|
48
|
+
raise ValueError('Verified native comment lacks published media or durable receipt')
|
|
49
|
+
|
|
50
|
+
def approval(value):
|
|
51
|
+
shape(value, ('schema', 'intent', 'payload', 'media'))
|
|
52
|
+
if value['schema'] != SCHEMA:
|
|
53
|
+
raise ValueError('Invalid native approval schema')
|
|
54
|
+
intent = value['intent']
|
|
55
|
+
shape(intent, ('brand','platform','account_id','legacy_entry_id','publish_at',
|
|
56
|
+
'timezone_name','dispatch_after','route','media_hash','content_hash'), ('schedule_mode',))
|
|
57
|
+
parsed = job(dict(intent, id=expected or 'remote-preview', created_at=intent['dispatch_after'], updated_at=intent['dispatch_after']))
|
|
58
|
+
validate_payload(parsed.platform, value['payload'])
|
|
59
|
+
if not isinstance(value['media'], list):
|
|
60
|
+
raise ValueError('Invalid native media hashes')
|
|
61
|
+
for media in value['media']:
|
|
62
|
+
shape(media, ('path', 'sha256'))
|
|
63
|
+
if hash_value(value['payload']) != parsed.content_hash or hash_value(value['media']) != parsed.media_hash:
|
|
64
|
+
raise ValueError('Native approval content mismatch')
|
|
65
|
+
|
|
66
|
+
def preview(value):
|
|
67
|
+
shape(value, ('kind','job_id','remote_ref','account_id','platform','route','before','after','payload','consequence'))
|
|
68
|
+
before, after = job(value['before']), job(value['after'])
|
|
69
|
+
target(value['job_id'])
|
|
70
|
+
if (before.id != after.id or before.id != value['job_id'] or before.account_id != after.account_id
|
|
71
|
+
or before.platform != after.platform or before.remote_id != value['remote_ref']
|
|
72
|
+
or before.account_id != value['account_id'] or before.platform != value['platform']
|
|
73
|
+
or value['kind'] not in {'cancel','reschedule'} or value['route'] != 'official_ui'
|
|
74
|
+
or hash_value(value['payload']) != before.content_hash or before.content_hash != after.content_hash):
|
|
75
|
+
raise ValueError('Mismatched native action preview')
|
|
76
|
+
if command in {'cancel','reschedule'} and value['kind'] != command:
|
|
77
|
+
raise ValueError('Wrong native action kind')
|
|
78
|
+
|
|
79
|
+
def action(value):
|
|
80
|
+
shape(value, ('id','kind','digest','status','job','preview'), ('submitted_at','created_at'))
|
|
81
|
+
parsed = job(value['job'])
|
|
82
|
+
preview(value['preview'])
|
|
83
|
+
if (value['status'] not in {'approved','submitted','confirmed'} or value['kind'] != value['preview']['kind']
|
|
84
|
+
or value['digest'] != hash_value(value['preview']) or parsed.model_dump(mode='json') != value['preview']['after']):
|
|
85
|
+
raise ValueError('Invalid native action')
|
|
86
|
+
|
|
87
|
+
for annotated in rows:
|
|
88
|
+
row = {k: v for k, v in annotated.items() if k != 'next_action'}
|
|
89
|
+
if row.get('source') == 'checkpoint':
|
|
90
|
+
shape(row, ('job_id','attempt_id','remote_id','source'))
|
|
91
|
+
# Snapshots may include other jobs in the same explicit brand.
|
|
92
|
+
known = by_id.get(row['job_id'])
|
|
93
|
+
if known is None or known['attempt_id'] != row['attempt_id'] or not isinstance(row['remote_id'], str) or not row['remote_id'].strip():
|
|
94
|
+
raise ValueError('Invalid checkpoint identity')
|
|
95
|
+
elif command == 'prepare':
|
|
96
|
+
approval(row)
|
|
97
|
+
elif command in {'dispatch','reconcile'}:
|
|
98
|
+
required = ('job_id','state','notices') + (('observations','instruction') if command == 'reconcile' else ())
|
|
99
|
+
shape(row, required, ('first_comment',))
|
|
100
|
+
known = target(row['job_id'])
|
|
101
|
+
if 'first_comment' in row:
|
|
102
|
+
first_comment(row['first_comment'], known)
|
|
103
|
+
if row['state'] != known['state'] or not isinstance(row['notices'], list):
|
|
104
|
+
raise ValueError('Mismatched native result state')
|
|
105
|
+
for raw in row.get('observations', []):
|
|
106
|
+
obs = NativeObservation.model_validate_json(json.dumps(raw))
|
|
107
|
+
if obs.job_id != known['id'] or obs.platform != known['platform'] or obs.account_id != known['account_id']:
|
|
108
|
+
raise ValueError('Mismatched native observation')
|
|
109
|
+
elif command in {'cancel','reschedule'}:
|
|
110
|
+
if 'document' in row:
|
|
111
|
+
shape(row, ('document','digest'))
|
|
112
|
+
preview(row['document'])
|
|
113
|
+
if row['digest'] != hash_value(row['document']):
|
|
114
|
+
raise ValueError('Mismatched action digest')
|
|
115
|
+
else:
|
|
116
|
+
action(row)
|
|
117
|
+
elif command == 'handoff':
|
|
118
|
+
if 'action' in row:
|
|
119
|
+
shape(row, ('action','upload_allowed','instruction'))
|
|
120
|
+
action(row['action'])
|
|
121
|
+
if row['upload_allowed'] is not False:
|
|
122
|
+
raise ValueError('Action handoff must not upload')
|
|
123
|
+
else:
|
|
124
|
+
shape(row, ('schema','state','job','payload','schedule','approval_digest','content_hash','media_hash','media','media_integrity','delivery','operator_policy'))
|
|
125
|
+
shape(row['job'], ('id','brand','platform','account_id'))
|
|
126
|
+
known = target(row['job']['id'])
|
|
127
|
+
if row['job'] != {k: known[k] for k in ('id','brand','platform','account_id')}:
|
|
128
|
+
raise ValueError('Mismatched handoff identity')
|
|
129
|
+
for key in ('state','approval_digest','content_hash','media_hash'):
|
|
130
|
+
if row[key] != known[key]:
|
|
131
|
+
raise ValueError('Mismatched handoff state')
|
|
132
|
+
from datetime import datetime
|
|
133
|
+
from zoneinfo import ZoneInfo
|
|
134
|
+
when = datetime.fromisoformat(known['publish_at'].replace('Z', '+00:00'))
|
|
135
|
+
shape(row['schedule'], ('local','utc','timezone'))
|
|
136
|
+
if (row['schema'] != 'socialctl.native-schedule.handoff.v1'
|
|
137
|
+
or row['schedule']['timezone'] != known['timezone_name']
|
|
138
|
+
or datetime.fromisoformat(row['schedule']['utc'].replace('Z', '+00:00')) != when
|
|
139
|
+
or row['schedule']['local'] != when.astimezone(ZoneInfo(known['timezone_name'])).isoformat()):
|
|
140
|
+
raise ValueError('Mismatched handoff schedule')
|
|
141
|
+
if hash_value(row['payload']) != known['content_hash']:
|
|
142
|
+
raise ValueError('Mismatched handoff payload')
|
|
143
|
+
elif command == 'deliver':
|
|
144
|
+
shape(row, ('delivered','awaiting_ui','instruction'), ('needs_attention','first_comments'))
|
|
145
|
+
comments = row.get('first_comments',[])
|
|
146
|
+
if not isinstance(comments,list):
|
|
147
|
+
raise ValueError('Invalid native first-comment list')
|
|
148
|
+
by_comment_job = {}
|
|
149
|
+
for comment in comments:
|
|
150
|
+
shape(comment, ('job_id','status'), ('publication_id','change_id','comment_id'))
|
|
151
|
+
known = target(comment['job_id'])
|
|
152
|
+
if comment['job_id'] in by_comment_job:
|
|
153
|
+
raise ValueError('Duplicate native first-comment result')
|
|
154
|
+
by_comment_job[comment['job_id']] = comment
|
|
155
|
+
first_comment({k:v for k,v in comment.items() if k != 'job_id'},known)
|
|
156
|
+
if not isinstance(row['awaiting_ui'], list) or any(target(i)['state'] != 'awaiting_ui' for i in row['awaiting_ui']):
|
|
157
|
+
raise ValueError('Invalid delivered jobs')
|
|
158
|
+
attention = row.get('needs_attention', [])
|
|
159
|
+
if not isinstance(attention, list) or any(target(i)['state'] not in {'blocked', 'uncertain'}
|
|
160
|
+
and by_comment_job.get(i,{}).get('status') not in {'uncertain','manual_review','observed_existing','rejected','failed','conflict','applying','partial','accepted_unverifiable'} for i in attention):
|
|
161
|
+
raise ValueError('Invalid jobs requiring attention')
|
|
162
|
+
elif command == 'migration-prepare':
|
|
163
|
+
shape(row, ('version','brand','brand_root','manifest','manifest_hash','inventory','legacy_entries','legacy_results','native_jobs','transfers','excluded','protections','instruction','digest'))
|
|
164
|
+
if row['version'] != 1 or row['brand'] != brand or row['digest'] != hash_value({k:v for k,v in row.items() if k != 'digest'}):
|
|
165
|
+
raise ValueError('Invalid migration proposal')
|
|
166
|
+
for raw in row['native_jobs']:
|
|
167
|
+
job(raw)
|
|
168
|
+
for transfer in row['transfers']:
|
|
169
|
+
job(transfer['job'])
|
|
170
|
+
approval(transfer['native_preview'])
|
|
171
|
+
elif command in {'migration-apply','migration-recover'}:
|
|
172
|
+
shape(row, ('status','digest','publication_approved'), ('prepared','historical_guard_retained'))
|
|
173
|
+
if row['status'] != 'committed' or row['publication_approved'] is not False or row['digest'] != request_args[request_args.index('--digest')+1]:
|
|
174
|
+
raise ValueError('Invalid migration result')
|
|
175
|
+
else:
|
|
176
|
+
# status/approve/submit-marker emit snapshots only.
|
|
177
|
+
raise ValueError('Unexpected native result variant')
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Bound native command envelopes; provider state and command exit stay separate."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import redirect_stdout
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from socialctl.agent_errors import error_document, error_for_code
|
|
10
|
+
from .approval import reject_secrets
|
|
11
|
+
from .models import NativeJob
|
|
12
|
+
|
|
13
|
+
active_report = ContextVar('native_report', default=None)
|
|
14
|
+
READ_ONLY = {'status', 'handoff', 'migration-prepare'}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def with_action(value):
|
|
18
|
+
codes = {'prepared': 'approval_required', 'approved': 'native_wait',
|
|
19
|
+
'waiting_window': 'native_wait', 'ready': 'native_dispatch',
|
|
20
|
+
'awaiting_ui': 'native_ui_required', 'ui_required': 'native_ui_required',
|
|
21
|
+
'conflict': 'operation_rejected', 'dispatching': 'operation_uncertain',
|
|
22
|
+
'uncertain': 'operation_uncertain', 'verifying': 'native_verify',
|
|
23
|
+
'pending_verification': 'native_verify', 'native_scheduled': 'native_verify',
|
|
24
|
+
'blocked': 'operation_rejected', 'unsupported': 'unsupported_operation'}
|
|
25
|
+
result = {k: v for k, v in value.items() if k != 'next_action'}
|
|
26
|
+
code = codes.get(result.get('state'))
|
|
27
|
+
if code:
|
|
28
|
+
result['next_action'] = error_for_code(code, read_only=False)
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_remote(text, *, command, brand, exit_code, request_args=()):
|
|
33
|
+
if len(text) > 1_000_000:
|
|
34
|
+
raise ValueError('Oversized native envelope')
|
|
35
|
+
value = json.loads(text)
|
|
36
|
+
fields = {'version', 'kind', 'command', 'brand', 'status', 'exit_code', 'data', 'jobs', 'jobs_complete', 'errors'}
|
|
37
|
+
if (not isinstance(value, dict) or set(value) != fields or type(value['version']) is not int
|
|
38
|
+
or value['version'] != 1 or value['kind'] != 'native-schedule'
|
|
39
|
+
or value['command'] != command or value['brand'] != brand
|
|
40
|
+
or type(value['exit_code']) is not int or value['exit_code'] != exit_code
|
|
41
|
+
or not 0 <= exit_code <= 255 or value['status'] != ('ok' if exit_code == 0 else 'error')
|
|
42
|
+
or type(value['jobs_complete']) is not bool):
|
|
43
|
+
raise ValueError('Mismatched native envelope')
|
|
44
|
+
for key in ('data', 'jobs', 'errors'):
|
|
45
|
+
if not isinstance(value[key], list) or any(not isinstance(row, dict) for row in value[key]):
|
|
46
|
+
raise ValueError('Invalid native envelope rows')
|
|
47
|
+
for row in value['jobs']:
|
|
48
|
+
base = {k: v for k, v in row.items() if k != 'next_action'}
|
|
49
|
+
reject_secrets(base)
|
|
50
|
+
NativeJob.model_validate_json(json.dumps(base))
|
|
51
|
+
if base['brand'] != brand:
|
|
52
|
+
raise ValueError('Mismatched native job brand')
|
|
53
|
+
for row in value['data']:
|
|
54
|
+
reject_secrets({k: v for k, v in row.items() if k != 'next_action'})
|
|
55
|
+
from .remote_data import validate_data
|
|
56
|
+
validate_data(value['data'], command=command, brand=brand, jobs=value['jobs'], request_args=request_args)
|
|
57
|
+
# Only internal catalogue codes supply remote action semantics, never an
|
|
58
|
+
# arbitrary peer's message/action. Data/job safety is checked independently.
|
|
59
|
+
value['errors'] = [error_for_code(row.get('code'), read_only=command in READ_ONLY) for row in value['errors']]
|
|
60
|
+
value['jobs'] = [with_action(row) for row in value['jobs']]
|
|
61
|
+
value['data'] = [with_action(row) for row in value['data']]
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NativeReport:
|
|
66
|
+
def __init__(self, command, brand):
|
|
67
|
+
self.command, self.brand = command, brand
|
|
68
|
+
self.data, self.jobs, self.errors = [], [], []
|
|
69
|
+
self.store = None
|
|
70
|
+
self.started = False
|
|
71
|
+
self.jobs_complete = False
|
|
72
|
+
self.remote = None
|
|
73
|
+
|
|
74
|
+
def __enter__(self):
|
|
75
|
+
self.token = active_report.set(self)
|
|
76
|
+
self.redirect = redirect_stdout(sys.stderr)
|
|
77
|
+
self.redirect.__enter__()
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def bind(self, store):
|
|
81
|
+
self.store = store
|
|
82
|
+
self.snapshot()
|
|
83
|
+
|
|
84
|
+
def snapshot(self):
|
|
85
|
+
rows = [job.model_dump(mode='json') for job in self.store.list_jobs()]
|
|
86
|
+
reject_secrets(rows)
|
|
87
|
+
self.jobs = [with_action(row) for row in rows]
|
|
88
|
+
# Durable checkpoints can precede a job's final remote_id (for example,
|
|
89
|
+
# interruption after upload START). Expose IDs only, never upload URLs.
|
|
90
|
+
for row in rows:
|
|
91
|
+
for attempt in self.store.get_attempts(row['id']):
|
|
92
|
+
checkpoint = attempt['checkpoint']
|
|
93
|
+
remote_id = checkpoint.get('video_id') or checkpoint.get('remote_id')
|
|
94
|
+
if remote_id:
|
|
95
|
+
evidence = {'job_id': row['id'], 'attempt_id': attempt['id'],
|
|
96
|
+
'remote_id': remote_id, 'source': 'checkpoint'}
|
|
97
|
+
reject_secrets(evidence)
|
|
98
|
+
if evidence not in self.data:
|
|
99
|
+
self.data.append(evidence)
|
|
100
|
+
|
|
101
|
+
def __exit__(self, kind, exc, traceback):
|
|
102
|
+
code = 0 if exc is None else (exc.exit_code or 0) if isinstance(exc, typer.Exit) else 130 if isinstance(exc, KeyboardInterrupt) else 1
|
|
103
|
+
try:
|
|
104
|
+
if self.remote is None:
|
|
105
|
+
if exc is not None and code:
|
|
106
|
+
known = next((row['next_action'] for row in reversed(self.data)
|
|
107
|
+
if 'next_action' in row), None)
|
|
108
|
+
self.errors.append(known if isinstance(exc, typer.Exit) and known else
|
|
109
|
+
error_for_code('operation_failed') if isinstance(exc, typer.Exit) else
|
|
110
|
+
error_for_code('operation_uncertain') if self.started else
|
|
111
|
+
error_document(exc, read_only=self.command in READ_ONLY))
|
|
112
|
+
if self.store is not None:
|
|
113
|
+
try:
|
|
114
|
+
self.snapshot()
|
|
115
|
+
self.jobs_complete = True
|
|
116
|
+
except Exception:
|
|
117
|
+
self.errors.append(error_for_code('reporting_incomplete'))
|
|
118
|
+
code = code or 1
|
|
119
|
+
payload = {'version': 1, 'kind': 'native-schedule', 'command': self.command,
|
|
120
|
+
'brand': self.brand, 'status': 'ok' if code == 0 else 'error',
|
|
121
|
+
'exit_code': code, 'data': self.data, 'jobs': self.jobs,
|
|
122
|
+
'jobs_complete': self.jobs_complete, 'errors': self.errors}
|
|
123
|
+
else:
|
|
124
|
+
payload = self.remote
|
|
125
|
+
code = payload['exit_code']
|
|
126
|
+
finally:
|
|
127
|
+
active_report.reset(self.token)
|
|
128
|
+
self.redirect.__exit__(kind, exc, traceback)
|
|
129
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
130
|
+
if code:
|
|
131
|
+
raise typer.Exit(code) from None
|
|
132
|
+
return exc is not None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def bind_store(store):
|
|
136
|
+
report = active_report.get()
|
|
137
|
+
if report is not None:
|
|
138
|
+
report.bind(store)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def capture(value):
|
|
142
|
+
report = active_report.get()
|
|
143
|
+
if report is not None:
|
|
144
|
+
reject_secrets(value)
|
|
145
|
+
report.data.append(with_action(value))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def begin_submission():
|
|
149
|
+
report = active_report.get()
|
|
150
|
+
if report is not None:
|
|
151
|
+
report.started = True
|