autotouch-cli 0.2.22__tar.gz → 0.2.23__tar.gz

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.
Files changed (64) hide show
  1. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/PKG-INFO +10 -1
  2. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/autotouch_cli.egg-info/PKG-INFO +10 -1
  3. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/docs/research-table/reference/autotouch-cli-pypi.md +9 -0
  4. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/pyproject.toml +1 -1
  5. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/smart_table_cli.py +250 -1
  6. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/autotouch_cli.egg-info/SOURCES.txt +0 -0
  7. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/autotouch_cli.egg-info/dependency_links.txt +0 -0
  8. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/autotouch_cli.egg-info/entry_points.txt +0 -0
  9. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/autotouch_cli.egg-info/requires.txt +0 -0
  10. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/autotouch_cli.egg-info/top_level.txt +0 -0
  11. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/__init__.py +0 -0
  12. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/add_column_unique_index.py +0 -0
  13. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/attach_csv_import_leads_to_research_table.py +0 -0
  14. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/bundle_sequences_backend.py +0 -0
  15. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/check_agent_traces.py +0 -0
  16. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/check_column_mode.py +0 -0
  17. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/exit_terminal_leads_from_sequences.py +0 -0
  18. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/fetch_lead.py +0 -0
  19. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/fix_lead_titles_from_csv.py +0 -0
  20. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250106_add_column_position.py +0 -0
  21. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250108_fix_legacy_column_fields.py +0 -0
  22. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250109_add_user_fields_to_tables.py +0 -0
  23. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250117_add_call_logs_webhook_indexes.py +0 -0
  24. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250117_rename_call_logs_collection.py +0 -0
  25. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250119_create_leads_unique_email_index.py +0 -0
  26. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250123_add_filter_indexes.py +0 -0
  27. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250123_add_llm_responses_collection.py +0 -0
  28. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250128_migrate_user_ids_to_objectid.py +0 -0
  29. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250208_backfill_task_research_values.py +0 -0
  30. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250604_add_origin_indexes.py +0 -0
  31. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250608_cleanup_agent_metadata.py +0 -0
  32. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250608_rename_agent_metadata_to_metadata.py +0 -0
  33. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250922_add_activity_indexes.py +0 -0
  34. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250926_migrate_single_to_arrays.py +0 -0
  35. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250928_add_missing_timestamp_fields.py +0 -0
  36. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250929_add_task_join_indexes.py +0 -0
  37. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250929_add_task_join_indexes_safe.py +0 -0
  38. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20250929_create_shared_phone_cache.py +0 -0
  39. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20251007_add_rows_position_id_index.py +0 -0
  40. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20251109_add_ttl_for_llm_and_preview_traces.py +0 -0
  41. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20260113_normalize_table_filter_operators.py +0 -0
  42. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20260113_set_user_permissions_user_admin.py +0 -0
  43. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20260204_sync_lead_owner_from_tasks.py +0 -0
  44. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20260303_add_webhook_subscription_collections.py +0 -0
  45. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/20260305_force_formatter_autorun_on_source_update.py +0 -0
  46. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/migrate_org_user_credits.py +0 -0
  47. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/set_default_lead_status.py +0 -0
  48. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/migrations/update_lead_owner_from_tasks.py +0 -0
  49. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/reassign_sequence_owner.py +0 -0
  50. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/run_sidecar_orchestrator_demo.py +0 -0
  51. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/test_crm_company_policy.py +0 -0
  52. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/test_sequences_instantly_e2e.py +0 -0
  53. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/test_sequences_personal_e2e.py +0 -0
  54. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/test_task_error_logger.py +0 -0
  55. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/scripts/verify_azurite_voicemail.py +0 -0
  56. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/setup.cfg +0 -0
  57. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_contactout_custom.py +0 -0
  58. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_contactout_integration.py +0 -0
  59. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_contactout_multi_titles.py +0 -0
  60. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_contactout_pipeline.py +0 -0
  61. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_contactout_simple.py +0 -0
  62. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_contactout_v2_bulk.py +0 -0
  63. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_lead_required_fields.py +0 -0
  64. {autotouch_cli-0.2.22 → autotouch_cli-0.2.23}/tests/test_phone_provider_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autotouch-cli
3
- Version: 0.2.22
3
+ Version: 0.2.23
4
4
  Summary: Autotouch Smart Table CLI
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -207,6 +207,12 @@ Notes:
207
207
  - `add_to_crm` is optional and non-billable.
208
208
  - Email/phone enrichment does not require creating/running `add_to_crm`.
209
209
  - For `add_to_crm`, required mapping keys are `linkedinUrl` and `companyDomain`.
210
+ - CRM data model expectations for `add_to_crm`:
211
+ - Lead identity/dedupe expects `linkedin_url` + `company_domain` (clean domain format).
212
+ - `company_domain` is required; `company_name` is a display hint applied to the linked Company record.
213
+ - Leads link to companies via `company_id`; canonical company name lives on the Company document.
214
+ - Canonical lead contact fields are arrays (`email_addresses[]`, `phone_numbers[]`); legacy scalar fields may exist but are fallback only.
215
+ - Reference: `docs/data/leads.md`, `docs/data/companies.md`.
210
216
  - Formatter formulas must use row references (`row['first_name']`, `row.last_name`), not bare template placeholders like ``${first_name}``.
211
217
  - `sync_to_table` supports both:
212
218
  - single destination: `destinationTableId` + `columnMappings`
@@ -243,6 +249,9 @@ If path A is missing, continue to B/C before declaring `not_found`.
243
249
  JSON split note:
244
250
  - Optional by default.
245
251
  - Use only when downstream filters/mappings need stable flat keys.
252
+ - If source columns are JSON enrichments, run with `--wait` and confirm terminal job status before splitting.
253
+ - CLI behavior: `columns projections` emits preflight warnings when a JSON enrichment source appears unrun/unverified.
254
+ - Warning output contract: when warnings exist, JSON output is wrapped as `{ "event": "projections.created_with_warnings", "warnings": [...], "result": <api_response> }`.
246
255
 
247
256
  Reference:
248
257
  - `docs/research-table/guides/context-first-sequence-playbook.md`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autotouch-cli
3
- Version: 0.2.22
3
+ Version: 0.2.23
4
4
  Summary: Autotouch Smart Table CLI
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -207,6 +207,12 @@ Notes:
207
207
  - `add_to_crm` is optional and non-billable.
208
208
  - Email/phone enrichment does not require creating/running `add_to_crm`.
209
209
  - For `add_to_crm`, required mapping keys are `linkedinUrl` and `companyDomain`.
210
+ - CRM data model expectations for `add_to_crm`:
211
+ - Lead identity/dedupe expects `linkedin_url` + `company_domain` (clean domain format).
212
+ - `company_domain` is required; `company_name` is a display hint applied to the linked Company record.
213
+ - Leads link to companies via `company_id`; canonical company name lives on the Company document.
214
+ - Canonical lead contact fields are arrays (`email_addresses[]`, `phone_numbers[]`); legacy scalar fields may exist but are fallback only.
215
+ - Reference: `docs/data/leads.md`, `docs/data/companies.md`.
210
216
  - Formatter formulas must use row references (`row['first_name']`, `row.last_name`), not bare template placeholders like ``${first_name}``.
211
217
  - `sync_to_table` supports both:
212
218
  - single destination: `destinationTableId` + `columnMappings`
@@ -243,6 +249,9 @@ If path A is missing, continue to B/C before declaring `not_found`.
243
249
  JSON split note:
244
250
  - Optional by default.
245
251
  - Use only when downstream filters/mappings need stable flat keys.
252
+ - If source columns are JSON enrichments, run with `--wait` and confirm terminal job status before splitting.
253
+ - CLI behavior: `columns projections` emits preflight warnings when a JSON enrichment source appears unrun/unverified.
254
+ - Warning output contract: when warnings exist, JSON output is wrapped as `{ "event": "projections.created_with_warnings", "warnings": [...], "result": <api_response> }`.
246
255
 
247
256
  Reference:
248
257
  - `docs/research-table/guides/context-first-sequence-playbook.md`
@@ -198,6 +198,12 @@ Notes:
198
198
  - `add_to_crm` is optional and non-billable.
199
199
  - Email/phone enrichment does not require creating/running `add_to_crm`.
200
200
  - For `add_to_crm`, required mapping keys are `linkedinUrl` and `companyDomain`.
201
+ - CRM data model expectations for `add_to_crm`:
202
+ - Lead identity/dedupe expects `linkedin_url` + `company_domain` (clean domain format).
203
+ - `company_domain` is required; `company_name` is a display hint applied to the linked Company record.
204
+ - Leads link to companies via `company_id`; canonical company name lives on the Company document.
205
+ - Canonical lead contact fields are arrays (`email_addresses[]`, `phone_numbers[]`); legacy scalar fields may exist but are fallback only.
206
+ - Reference: `docs/data/leads.md`, `docs/data/companies.md`.
201
207
  - Formatter formulas must use row references (`row['first_name']`, `row.last_name`), not bare template placeholders like ``${first_name}``.
202
208
  - `sync_to_table` supports both:
203
209
  - single destination: `destinationTableId` + `columnMappings`
@@ -234,6 +240,9 @@ If path A is missing, continue to B/C before declaring `not_found`.
234
240
  JSON split note:
235
241
  - Optional by default.
236
242
  - Use only when downstream filters/mappings need stable flat keys.
243
+ - If source columns are JSON enrichments, run with `--wait` and confirm terminal job status before splitting.
244
+ - CLI behavior: `columns projections` emits preflight warnings when a JSON enrichment source appears unrun/unverified.
245
+ - Warning output contract: when warnings exist, JSON output is wrapped as `{ "event": "projections.created_with_warnings", "warnings": [...], "result": <api_response> }`.
237
246
 
238
247
  Reference:
239
248
  - `docs/research-table/guides/context-first-sequence-playbook.md`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "autotouch-cli"
7
- version = "0.2.22"
7
+ version = "0.2.23"
8
8
  description = "Autotouch Smart Table CLI"
9
9
  readme = "docs/research-table/reference/autotouch-cli-pypi.md"
10
10
  requires-python = ">=3.9"
@@ -57,6 +57,14 @@ TERMINAL_JOB_STATUSES = {
57
57
  "failed",
58
58
  "completed_with_errors",
59
59
  }
60
+ NON_TERMINAL_JOB_STATUSES = {
61
+ "queued",
62
+ "distributing",
63
+ "processing",
64
+ "running",
65
+ "pending",
66
+ }
67
+ PROJECTION_READINESS_SAMPLE_SIZE = 50
60
68
  CONFIG_ENV_KEY = "AUTOTOUCH_CONFIG_PATH"
61
69
  CONFIG_DIR_NAME = "autotouch"
62
70
  CONFIG_FILE_NAME = "config.json"
@@ -256,6 +264,7 @@ RUN_SOP_GUIDE: Dict[str, Any] = {
256
264
  "For add_to_crm, generate payload from columns recipe before create/update.",
257
265
  "Keep unprocessedOnly enabled unless you intentionally reprocess rows.",
258
266
  "Treat bulk job state as source of truth: queued/distributing/processing are non-terminal; completed/partial/error/cancelled are terminal.",
267
+ "For JSON enrichment columns, run with --wait and verify terminal status before creating projection splits.",
259
268
  "Always inspect raw outputs before summary counts; parse by column dataType (json vs scalar).",
260
269
  "When summarizing enrichment outputs, parse by key precedence (phone: mobile_number -> phone_numbers[0].number -> primary_phone; email: response -> email -> work_email).",
261
270
  ],
@@ -1028,6 +1037,224 @@ def _resolve_column_key(
1028
1037
  sys.exit(1)
1029
1038
 
1030
1039
 
1040
+ def _extract_columns_list(columns_raw: Any) -> List[Dict[str, Any]]:
1041
+ if isinstance(columns_raw, list):
1042
+ return [col for col in columns_raw if isinstance(col, dict)]
1043
+ if isinstance(columns_raw, dict):
1044
+ candidates = columns_raw.get("columns") or columns_raw.get("items") or columns_raw.get("data") or []
1045
+ if isinstance(candidates, list):
1046
+ return [col for col in candidates if isinstance(col, dict)]
1047
+ return []
1048
+
1049
+
1050
+ def _extract_jobs_list(jobs_raw: Any) -> List[Dict[str, Any]]:
1051
+ if isinstance(jobs_raw, list):
1052
+ return [job for job in jobs_raw if isinstance(job, dict)]
1053
+ if isinstance(jobs_raw, dict):
1054
+ candidates = jobs_raw.get("jobs") or jobs_raw.get("items") or jobs_raw.get("data") or []
1055
+ if isinstance(candidates, list):
1056
+ return [job for job in candidates if isinstance(job, dict)]
1057
+ return []
1058
+
1059
+
1060
+ def _is_json_enrichment_column(column: Dict[str, Any]) -> bool:
1061
+ kind = str(column.get("kind") or "").strip().lower()
1062
+ data_type = str(column.get("dataType") or column.get("data_type") or "").strip().lower()
1063
+ return kind == "enrichment" and data_type == "json"
1064
+
1065
+
1066
+ def _projection_recommended_run_command(table_id: str, column_id: str) -> str:
1067
+ return (
1068
+ f"autotouch columns run --table-id {table_id} --column-id {column_id} "
1069
+ "--scope all --show-estimate --wait --output json"
1070
+ )
1071
+
1072
+
1073
+ def _projection_preflight_warn(
1074
+ *,
1075
+ code: str,
1076
+ column: Dict[str, Any],
1077
+ reason: str,
1078
+ recommended_next_command: str,
1079
+ latest_job_status: Optional[str] = None,
1080
+ column_status: Optional[str] = None,
1081
+ sample_rows_scanned: Optional[int] = None,
1082
+ sample_non_empty_values: Optional[int] = None,
1083
+ ) -> Dict[str, Any]:
1084
+ warning: Dict[str, Any] = {
1085
+ "code": code,
1086
+ "severity": "warning",
1087
+ "source_column_id": str(column.get("id") or column.get("_id") or ""),
1088
+ "source_column_key": str(column.get("key") or ""),
1089
+ "source_column_label": str(column.get("label") or column.get("key") or ""),
1090
+ "reason": reason,
1091
+ "recommended_next_command": recommended_next_command,
1092
+ }
1093
+ if latest_job_status:
1094
+ warning["latest_job_status"] = latest_job_status
1095
+ if column_status:
1096
+ warning["column_status"] = column_status
1097
+ if sample_rows_scanned is not None:
1098
+ warning["sample_rows_scanned"] = int(sample_rows_scanned)
1099
+ if sample_non_empty_values is not None:
1100
+ warning["sample_non_empty_values"] = int(sample_non_empty_values)
1101
+ return warning
1102
+
1103
+
1104
+ def _print_projection_preflight_warnings(warnings: List[Dict[str, Any]]) -> None:
1105
+ if not warnings:
1106
+ return
1107
+ print(
1108
+ f"WARNING: projection preflight found {len(warnings)} source column(s) that may not be ready yet.",
1109
+ file=sys.stderr,
1110
+ )
1111
+ for warning in warnings:
1112
+ col_label = warning.get("source_column_label") or warning.get("source_column_key") or warning.get("source_column_id")
1113
+ col_id = warning.get("source_column_id") or "unknown"
1114
+ reason = warning.get("reason") or "source may be unprocessed"
1115
+ print(f"WARNING: [{warning.get('code')}] {col_label} ({col_id}): {reason}", file=sys.stderr)
1116
+ cmd = warning.get("recommended_next_command")
1117
+ if cmd:
1118
+ print(f" Recommended: {cmd}", file=sys.stderr)
1119
+
1120
+
1121
+ def _build_projection_preflight_warnings(
1122
+ *,
1123
+ table_id: str,
1124
+ payload: Dict[str, Any],
1125
+ base_url: str,
1126
+ token: str,
1127
+ use_x_api_key: bool,
1128
+ timeout: int,
1129
+ verbose: bool,
1130
+ ) -> List[Dict[str, Any]]:
1131
+ items = payload.get("items")
1132
+ if not isinstance(items, list) or not items:
1133
+ return []
1134
+
1135
+ source_ids: List[str] = []
1136
+ seen: set[str] = set()
1137
+ for item in items:
1138
+ if not isinstance(item, dict):
1139
+ continue
1140
+ raw_source = item.get("sourceColumnId") or item.get("source_column_id")
1141
+ source_id = str(raw_source or "").strip()
1142
+ if not source_id or source_id in seen:
1143
+ continue
1144
+ seen.add(source_id)
1145
+ source_ids.append(source_id)
1146
+ if not source_ids:
1147
+ return []
1148
+
1149
+ columns_raw = _request_api(
1150
+ "GET",
1151
+ f"/api/tables/{table_id}/columns",
1152
+ base_url=base_url,
1153
+ token=token,
1154
+ use_x_api_key=use_x_api_key,
1155
+ timeout=timeout,
1156
+ verbose=verbose,
1157
+ )
1158
+ columns = _extract_columns_list(columns_raw)
1159
+ by_id: Dict[str, Dict[str, Any]] = {}
1160
+ for col in columns:
1161
+ cid = str(col.get("id") or col.get("_id") or "").strip()
1162
+ if cid:
1163
+ by_id[cid] = col
1164
+
1165
+ warnings: List[Dict[str, Any]] = []
1166
+ rows_sample: Optional[List[Dict[str, Any]]] = None
1167
+ jobs_cache: Dict[str, Optional[Dict[str, Any]]] = {}
1168
+
1169
+ for source_id in source_ids:
1170
+ source_col = by_id.get(source_id)
1171
+ if not source_col or not _is_json_enrichment_column(source_col):
1172
+ continue
1173
+
1174
+ source_col_id = str(source_col.get("id") or source_col.get("_id") or source_id)
1175
+ source_key = str(source_col.get("key") or "").strip()
1176
+ recommended_next = _projection_recommended_run_command(table_id, source_col_id)
1177
+
1178
+ latest_job = jobs_cache.get(source_col_id)
1179
+ if source_col_id not in jobs_cache:
1180
+ jobs_raw = _request_api(
1181
+ "GET",
1182
+ "/api/bulk-jobs",
1183
+ base_url=base_url,
1184
+ token=token,
1185
+ use_x_api_key=use_x_api_key,
1186
+ params={"table_id": table_id, "column_id": source_col_id, "limit": 1},
1187
+ timeout=timeout,
1188
+ verbose=verbose,
1189
+ )
1190
+ jobs = _extract_jobs_list(jobs_raw)
1191
+ latest_job = jobs[0] if jobs else None
1192
+ jobs_cache[source_col_id] = latest_job
1193
+
1194
+ latest_job_status = str((latest_job or {}).get("status") or "").strip().lower()
1195
+ if latest_job_status in NON_TERMINAL_JOB_STATUSES:
1196
+ warnings.append(
1197
+ _projection_preflight_warn(
1198
+ code="source_job_not_terminal",
1199
+ column=source_col,
1200
+ reason=f"latest run status is '{latest_job_status}' (not terminal)",
1201
+ recommended_next_command=recommended_next,
1202
+ latest_job_status=latest_job_status,
1203
+ column_status=str(source_col.get("status") or "").strip().lower() or None,
1204
+ )
1205
+ )
1206
+ continue
1207
+ if latest_job_status in TERMINAL_JOB_STATUSES:
1208
+ continue
1209
+
1210
+ has_run_metadata = bool(
1211
+ source_col.get("lastRunAt")
1212
+ or source_col.get("last_run_at")
1213
+ or source_col.get("lastJobId")
1214
+ or source_col.get("last_job_id")
1215
+ )
1216
+ if has_run_metadata:
1217
+ continue
1218
+
1219
+ if rows_sample is None:
1220
+ rows_page_raw = _request_api(
1221
+ "GET",
1222
+ f"/api/tables/{table_id}/rows",
1223
+ base_url=base_url,
1224
+ token=token,
1225
+ use_x_api_key=use_x_api_key,
1226
+ params={"page_size": PROJECTION_READINESS_SAMPLE_SIZE},
1227
+ timeout=timeout,
1228
+ verbose=verbose,
1229
+ )
1230
+ if not isinstance(rows_page_raw, dict):
1231
+ rows_sample = []
1232
+ else:
1233
+ candidate_rows = rows_page_raw.get("rows") or []
1234
+ rows_sample = [row for row in candidate_rows if isinstance(row, dict)] if isinstance(candidate_rows, list) else []
1235
+
1236
+ sample_count = 0
1237
+ if source_key:
1238
+ sample_count = sum(1 for row in (rows_sample or []) if _is_processed_cell_value(row.get(source_key)))
1239
+ if sample_count > 0:
1240
+ continue
1241
+
1242
+ warnings.append(
1243
+ _projection_preflight_warn(
1244
+ code="source_unrun_or_empty",
1245
+ column=source_col,
1246
+ reason="source JSON enrichment has no terminal run evidence and sampled values are empty",
1247
+ recommended_next_command=recommended_next,
1248
+ latest_job_status=latest_job_status or None,
1249
+ column_status=str(source_col.get("status") or "").strip().lower() or None,
1250
+ sample_rows_scanned=len(rows_sample or []),
1251
+ sample_non_empty_values=sample_count,
1252
+ )
1253
+ )
1254
+
1255
+ return warnings
1256
+
1257
+
1031
1258
  def _is_processed_cell_value(value: Any) -> bool:
1032
1259
  if value is None:
1033
1260
  return False
@@ -2226,7 +2453,7 @@ def cmd_capabilities(args: argparse.Namespace) -> None:
2226
2453
  basic_types_str = ", ".join(str(t) for t in basic_types) if isinstance(basic_types, list) else "text/json"
2227
2454
  print(f"basic : {basic_types_str} (JSON requires dataType=json)")
2228
2455
  print("")
2229
- print("Tip: use `autotouch columns projections` + formatter columns for JSON pipeline chaining.")
2456
+ print("Tip: for JSON enrichment columns, run with `--wait` and verify terminal status before `autotouch columns projections`.")
2230
2457
  return
2231
2458
 
2232
2459
  _print_json(data, compact=args.compact)
@@ -3343,6 +3570,18 @@ def cmd_columns_projections(args: argparse.Namespace) -> None:
3343
3570
  if not isinstance(payload, dict):
3344
3571
  print("ERROR: projection create requires --data-json/--data-file with a JSON object", file=sys.stderr)
3345
3572
  sys.exit(2)
3573
+
3574
+ preflight_warnings = _build_projection_preflight_warnings(
3575
+ table_id=args.table_id,
3576
+ payload=payload,
3577
+ base_url=args.base_url,
3578
+ token=token,
3579
+ use_x_api_key=args.use_x_api_key,
3580
+ timeout=args.timeout,
3581
+ verbose=args.verbose,
3582
+ )
3583
+ _print_projection_preflight_warnings(preflight_warnings)
3584
+
3346
3585
  data = _request_api(
3347
3586
  "POST",
3348
3587
  f"/api/tables/{args.table_id}/columns/projections",
@@ -3353,6 +3592,16 @@ def cmd_columns_projections(args: argparse.Namespace) -> None:
3353
3592
  timeout=args.timeout,
3354
3593
  verbose=args.verbose,
3355
3594
  )
3595
+ if preflight_warnings:
3596
+ _print_json(
3597
+ {
3598
+ "event": "projections.created_with_warnings",
3599
+ "warnings": preflight_warnings,
3600
+ "result": data,
3601
+ },
3602
+ compact=args.compact,
3603
+ )
3604
+ return
3356
3605
  _print_json(data, compact=args.compact)
3357
3606
 
3358
3607
 
File without changes