maxc-cli 0.8.1__tar.gz → 0.8.2__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 (136) hide show
  1. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/PKG-INFO +1 -1
  2. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/__init__.py +1 -1
  3. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/app.py +91 -35
  4. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/job.py +4 -2
  5. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/odps.py +18 -4
  6. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/query.py +0 -1
  7. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/cli.py +11 -2
  8. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/exceptions.py +1 -1
  9. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/models.py +6 -1
  10. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/output.py +21 -4
  11. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/utils.py +42 -7
  12. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/PKG-INFO +1 -1
  13. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_job_improvements.py +52 -0
  14. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_output_action_safety.py +25 -0
  15. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_output_format_contract.py +15 -0
  16. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_query_auto_promote.py +96 -0
  17. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_query_result_csv_fallback.py +26 -2
  18. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/MANIFEST.in +0 -0
  19. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/README.md +0 -0
  20. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/pyproject.toml +0 -0
  21. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/scripts/pyinstaller_entry.py +0 -0
  22. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/scripts/regression_test.py +0 -0
  23. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/setup.cfg +0 -0
  24. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/setup.py +0 -0
  25. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/__main__.py +0 -0
  26. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/_samples.py +0 -0
  27. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/agent_platforms.py +0 -0
  28. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/audit.py +0 -0
  29. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/auth_continuation.py +0 -0
  30. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/auth_providers.py +0 -0
  31. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/__init__.py +0 -0
  32. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/auth.py +0 -0
  33. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/catalog.py +0 -0
  34. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/data.py +0 -0
  35. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/mcp.py +0 -0
  36. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/meta.py +0 -0
  37. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/semantic.py +0 -0
  38. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/cache.py +0 -0
  39. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/catalog_bootstrap.py +0 -0
  40. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/config.py +0 -0
  41. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/enterprise_tls.py +0 -0
  42. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/help_format.py +0 -0
  43. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/helpers.py +0 -0
  44. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/job_ids.py +0 -0
  45. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/masking.py +0 -0
  46. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/mcp_serve.py +0 -0
  47. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/oauth.py +0 -0
  48. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/odps_runtime.py +0 -0
  49. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/proxy_auth.py +0 -0
  50. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/semantic.py +0 -0
  51. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/semantic_management.py +0 -0
  52. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/setting_parser.py +0 -0
  53. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/SKILL.md +0 -0
  54. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/agents/openai.yaml +0 -0
  55. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
  56. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
  57. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/command-patterns.md +0 -0
  58. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/json-output-format.md +0 -0
  59. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
  60. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
  61. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/partition-guide.md +0 -0
  62. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/red-lines.md +0 -0
  63. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/semantic-packages.md +0 -0
  64. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/setup-install.md +0 -0
  65. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
  66. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
  67. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
  68. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/state_permissions.py +0 -0
  69. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/store.py +0 -0
  70. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/SOURCES.txt +0 -0
  71. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
  72. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/entry_points.txt +0 -0
  73. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/requires.txt +0 -0
  74. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/top_level.txt +0 -0
  75. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_hints_and_cli.py +0 -0
  76. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_platforms.py +0 -0
  77. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_skill_commands.py +0 -0
  78. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_skill_commands_context.py +0 -0
  79. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_ai_native_contract_regressions.py +0 -0
  80. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_auth_logout.py +0 -0
  81. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_auth.py +0 -0
  82. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_data.py +0 -0
  83. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_data_serialization.py +0 -0
  84. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_mcp.py +0 -0
  85. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_meta.py +0 -0
  86. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_build_release_archive_compat.py +0 -0
  87. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_build_release_script.py +0 -0
  88. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cache.py +0 -0
  89. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_catalog.py +0 -0
  90. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_catalog_bootstrap.py +0 -0
  91. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cli_arg_validation.py +0 -0
  92. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cli_mock.py +0 -0
  93. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cli_query_parse_and_sanitize.py +0 -0
  94. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_compat.py +0 -0
  95. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_config_atomic_write.py +0 -0
  96. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_connection_bootstrap.py +0 -0
  97. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_e2e_smoke.py +0 -0
  98. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_effective_hints_contract.py +0 -0
  99. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_enterprise_tls.py +0 -0
  100. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_envelope_shape.py +0 -0
  101. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_error_self_correction.py +0 -0
  102. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_error_translation.py +0 -0
  103. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_exit_codes.py +0 -0
  104. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_external_auth.py +0 -0
  105. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_flag_hoist.py +0 -0
  106. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_help_format.py +0 -0
  107. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_help_version_e2e.py +0 -0
  108. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_helpers.py +0 -0
  109. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_helpers_csv.py +0 -0
  110. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_installer_contracts.py +0 -0
  111. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_integration.py +0 -0
  112. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_integration_real.py +0 -0
  113. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_job_inspection.py +0 -0
  114. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_job_store_durability.py +0 -0
  115. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_kb_commands.py +0 -0
  116. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_manifest_runtime_contract.py +0 -0
  117. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_masking.py +0 -0
  118. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_mcp_serve.py +0 -0
  119. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_meta_schema_and_partition_cols.py +0 -0
  120. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_oauth.py +0 -0
  121. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_odps_runtime.py +0 -0
  122. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_packaging_metadata.py +0 -0
  123. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_phase1_improvements.py +0 -0
  124. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_proxy_auth.py +0 -0
  125. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_pyinstaller_bundle.py +0 -0
  126. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_python39_compat.py +0 -0
  127. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_semantic_management.py +0 -0
  128. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_semantic_scope.py +0 -0
  129. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_semantic_transport.py +0 -0
  130. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_setting_parser.py +0 -0
  131. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_skill_cli_consistency.py +0 -0
  132. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_skill_eval.py +0 -0
  133. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_skill_renderer.py +0 -0
  134. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_startup_imports.py +0 -0
  135. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_state_permissions.py +0 -0
  136. {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_state_portability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.8.1
3
+ Version: 0.8.2
4
4
  Summary: Agent-native MaxCompute CLI for external coding agents
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.8.1"
5
+ __version__ = "0.8.2"
@@ -866,13 +866,22 @@ class MaxCApp:
866
866
  session["job_id"],
867
867
  project=session.get("project") or None,
868
868
  )
869
- result = self.backend.fetch_job_result(
870
- resolved.instance_id,
871
- project=resolved.project,
872
- max_rows=max_rows,
873
- offset=offset,
874
- session_context=resolved.session_context,
875
- )
869
+ try:
870
+ result = self.backend.fetch_job_result(
871
+ resolved.instance_id,
872
+ project=resolved.project,
873
+ max_rows=max_rows,
874
+ offset=offset,
875
+ session_context=resolved.session_context,
876
+ )
877
+ except Exception as exc:
878
+ error = exc if isinstance(exc, MaxCError) else translate_odps_error(exc)
879
+ envelope = self._job_result_fetch_failure(
880
+ command, resolved, None, error.to_payload(),
881
+ max_rows=max_rows, cursor=cursor,
882
+ )
883
+ self.log(command, envelope.status, envelope.metadata)
884
+ return envelope
876
885
  envelope = self._build_query_envelope(
877
886
  command=command,
878
887
  result=result,
@@ -1098,25 +1107,13 @@ class MaxCApp:
1098
1107
  except Exception as exc:
1099
1108
  fetch_error = translate_odps_error(exc).to_payload()
1100
1109
  if fetch_error is not None:
1101
- envelope = Envelope(
1102
- command=command,
1103
- status="failure",
1104
- data=None,
1105
- error=fetch_error,
1110
+ envelope = self._job_result_fetch_failure(
1111
+ command, resolved, job_info, fetch_error, max_rows=max_rows,
1106
1112
  metadata={
1107
- "job_id": resolved.external_job_id,
1108
- "project": resolved.project,
1109
- "logview": job_info.logview,
1110
1113
  "sql_executed": sql,
1111
1114
  **execution_metadata,
1112
1115
  },
1113
- agent_hints=AgentHints(
1114
- actions=[
1115
- action("job.result", data={"job_id": resolved.external_job_id}, metadata={"job_id": resolved.external_job_id, "project": resolved.project, "sql_executed": sql}),
1116
- action("job.status", data={"job_id": resolved.external_job_id}, metadata={"job_id": resolved.external_job_id, "project": resolved.project}),
1117
- ],
1118
- warnings=common_warnings,
1119
- ),
1116
+ warnings=common_warnings,
1120
1117
  )
1121
1118
  if idempotency_key:
1122
1119
  envelope.metadata["idempotency_key"] = idempotency_key
@@ -1458,12 +1455,20 @@ class MaxCApp:
1458
1455
  ]
1459
1456
  self.log("job.wait", envelope.status, envelope.metadata)
1460
1457
  return envelope, events
1461
- result = self.backend.fetch_job_result(
1462
- resolved.instance_id,
1463
- project=resolved.project,
1464
- max_rows=100,
1465
- session_context=resolved.session_context,
1466
- )
1458
+ try:
1459
+ result = self.backend.fetch_job_result(
1460
+ resolved.instance_id,
1461
+ project=resolved.project,
1462
+ max_rows=100,
1463
+ session_context=resolved.session_context,
1464
+ )
1465
+ except Exception as exc:
1466
+ error = exc if isinstance(exc, MaxCError) else translate_odps_error(exc)
1467
+ envelope = self._job_result_fetch_failure(
1468
+ "job.wait", resolved, after, error.to_payload(), max_rows=100,
1469
+ )
1470
+ self.log("job.wait", envelope.status, envelope.metadata)
1471
+ return envelope, []
1467
1472
  envelope = self._build_query_envelope(
1468
1473
  command="job.wait",
1469
1474
  result=result,
@@ -1561,13 +1566,22 @@ class MaxCApp:
1561
1566
  )
1562
1567
  self.log("job.result", envelope.status, envelope.metadata)
1563
1568
  return envelope
1564
- result = self.backend.fetch_job_result(
1565
- resolved.instance_id,
1566
- project=resolved.project,
1567
- max_rows=max_rows,
1568
- offset=offset,
1569
- session_context=resolved.session_context,
1570
- )
1569
+ try:
1570
+ result = self.backend.fetch_job_result(
1571
+ resolved.instance_id,
1572
+ project=resolved.project,
1573
+ max_rows=max_rows,
1574
+ offset=offset,
1575
+ session_context=resolved.session_context,
1576
+ )
1577
+ except Exception as exc:
1578
+ error = exc if isinstance(exc, MaxCError) else translate_odps_error(exc)
1579
+ envelope = self._job_result_fetch_failure(
1580
+ "job.result", resolved, info, error.to_payload(),
1581
+ max_rows=max_rows, cursor=cursor,
1582
+ )
1583
+ self.log("job.result", envelope.status, envelope.metadata)
1584
+ return envelope
1571
1585
  envelope = self._build_query_envelope(
1572
1586
  command="job.result",
1573
1587
  result=result,
@@ -1669,6 +1683,48 @@ class MaxCApp:
1669
1683
  self.log("job.result", envelope.status, envelope.metadata)
1670
1684
  return envelope
1671
1685
 
1686
+ def _job_result_fetch_failure(
1687
+ self,
1688
+ command: 'str',
1689
+ resolved: '_ResolvedExternalJobId',
1690
+ info: 'JobInfo | None',
1691
+ error: 'ErrorPayload',
1692
+ *,
1693
+ max_rows: 'int',
1694
+ cursor: 'str | None' = None,
1695
+ metadata: 'dict[str, Any] | None' = None,
1696
+ warnings: 'list[str] | None' = None,
1697
+ ) -> 'Envelope':
1698
+ """Keep the existing job and page observable when reading fails."""
1699
+ scope = {
1700
+ "job_id": resolved.external_job_id,
1701
+ "project": resolved.project,
1702
+ "logview": info.logview if info else error.logview,
1703
+ **({
1704
+ "job_status": info.status,
1705
+ "submitted_at": info.submitted_at,
1706
+ "completed_at": info.completed_at,
1707
+ } if info else {}),
1708
+ **(metadata or {}),
1709
+ }
1710
+ retry = action("job.result", data={"max_rows": max_rows, "cursor": cursor}, metadata=scope)
1711
+ error.instance_id = resolved.external_job_id
1712
+ error.logview = scope["logview"]
1713
+ error.recovery_steps = [
1714
+ "Retry reading the existing job result: " + retry.command,
1715
+ "Do not resubmit the SQL to recover a result-reader failure.",
1716
+ ]
1717
+ return Envelope(
1718
+ command=command,
1719
+ status="failure",
1720
+ error=error,
1721
+ metadata=scope,
1722
+ agent_hints=AgentHints(
1723
+ actions=[retry, action("job.diagnose", metadata=scope)],
1724
+ warnings=warnings or [],
1725
+ ),
1726
+ )
1727
+
1672
1728
  def _bound_job_result_cursor(
1673
1729
  self,
1674
1730
  cursor: 'str | None',
@@ -846,10 +846,12 @@ class JobMixin(QueryMixin):
846
846
  return sql.rstrip(";") if sql else None
847
847
 
848
848
  def _safe_logview(self, instance) -> 'str | None':
849
- """Get a LogView URL without exposing its signed query or fragment."""
849
+ """Get a usable SDK LogView URL for the caller, without URL userinfo."""
850
850
  from ..utils import sanitize_logview_url
851
851
 
852
852
  try:
853
- return sanitize_logview_url(instance.get_logview_address())
853
+ return sanitize_logview_url(
854
+ instance.get_logview_address(), include_access_token=True,
855
+ )
854
856
  except Exception:
855
857
  return None
@@ -152,7 +152,17 @@ class OdpsBackend(
152
152
  # timeout), the column metadata is parsed lazily from the
153
153
  # CSV header during the first __next__ call — so we can
154
154
  # only inspect it after iteration begins.
155
- records = list(islice(reader, offset, offset + max_rows))
155
+ iterator = iter(reader)
156
+ skipped = sum(1 for _ in islice(iterator, offset))
157
+ records = list(islice(iterator, max_rows))
158
+ count = getattr(reader, "count", None)
159
+ # CSV fallback readers expose no count. Count the
160
+ # remaining accessible records without retaining
161
+ # them so a page is not mistaken for the full result.
162
+ accessible_count = (
163
+ skipped + len(records) + sum(1 for _ in iterator)
164
+ if count is None else int(count)
165
+ )
156
166
 
157
167
  reader_schema = getattr(reader, "schema", None)
158
168
  if reader_schema is not None and hasattr(reader_schema, "columns"):
@@ -181,7 +191,7 @@ class OdpsBackend(
181
191
  record_to_dict(column_names, record.values)
182
192
  for record in records
183
193
  ]
184
- total_rows = int(getattr(reader, "count", len(rows)) or len(rows))
194
+ total_rows = accessible_count
185
195
  except Exception as reader_exc:
186
196
  # Some SHOW results (e.g. multi-column SHOW INDEXES
187
197
  # col_names) contain unquoted commas that make
@@ -197,7 +207,7 @@ class OdpsBackend(
197
207
  rows = all_raw_rows[offset : offset + max_rows]
198
208
  schema = [{"name": name, "type": "string", "comment": ""}
199
209
  for name in _RAW_RESULT_COLUMNS]
200
- total_rows = len(rows)
210
+ total_rows = len(all_raw_rows)
201
211
 
202
212
  for w in captured:
203
213
  msg_text = str(w.message)
@@ -210,7 +220,10 @@ class OdpsBackend(
210
220
  warnings.formatwarning(w.message, w.category, w.filename, w.lineno)
211
221
  )
212
222
  except Exception as exc:
213
- raise translate_odps_error(exc) from exc
223
+ error = translate_odps_error(exc)
224
+ error.instance_id = instance.id
225
+ error.logview = self._safe_logview(instance)
226
+ raise error from exc
214
227
 
215
228
  if degraded_reason is not None:
216
229
  fallback_warnings.append(
@@ -223,6 +236,7 @@ class OdpsBackend(
223
236
  returned_rows = len(rows)
224
237
  has_more = total_rows > (offset + returned_rows)
225
238
  extra_metadata["current_offset"] = offset
239
+ extra_metadata["logview"] = self._safe_logview(instance)
226
240
  if degraded_reason is not None:
227
241
  extra_metadata["result_kind"] = "raw_task_result"
228
242
  if resultless_statement:
@@ -506,7 +506,6 @@ class QueryMixin:
506
506
  submitted_at=now_utc_iso(),
507
507
  updated_at=now_utc_iso(),
508
508
  logview=self._safe_logview(instance),
509
- warnings=["The MaxCompute instance has been submitted; use job.status or job.wait to track it."],
510
509
  session_task_name=session_task_name,
511
510
  session_subquery_id=session_subquery_id,
512
511
  session_project_name=getattr(getattr(instance, "project", None), "name", None),
@@ -1786,7 +1786,7 @@ def run(
1786
1786
  if getattr(exc, "logview", None):
1787
1787
  from .utils import sanitize_logview_url
1788
1788
 
1789
- stderr.write(f" LogView: {sanitize_logview_url(exc.logview)}\n")
1789
+ stderr.write(f" LogView: {sanitize_logview_url(exc.logview, include_access_token=True)}\n")
1790
1790
  return exc.exit_code
1791
1791
  except Exception as exc:
1792
1792
  error_payload = ErrorPayload(
@@ -4446,7 +4446,7 @@ def _render_human(envelope: Envelope) -> str:
4446
4446
  if logview:
4447
4447
  from .utils import sanitize_logview_url
4448
4448
 
4449
- details["logview"] = sanitize_logview_url(logview)
4449
+ details["logview"] = sanitize_logview_url(logview, include_access_token=True)
4450
4450
  if error.context:
4451
4451
  details["context"] = error.context
4452
4452
  sections.append(render_key_values(details))
@@ -4491,11 +4491,20 @@ def _render_human(envelope: Envelope) -> str:
4491
4491
  )
4492
4492
  return "\n\n".join(section for section in sections if section)
4493
4493
 
4494
+ if envelope.status == "pending" and (command == "query" or command.startswith("job.")):
4495
+ from .output import render_markdown
4496
+
4497
+ return render_markdown(envelope)
4498
+
4494
4499
  if command == "query":
4500
+ from .utils import sanitize_logview_url
4501
+
4495
4502
  rows = data.get("rows", [])
4496
4503
  summary = render_key_values(
4497
4504
  {
4498
4505
  "status": envelope.status,
4506
+ "job_id": metadata.get("job_id"),
4507
+ "logview": sanitize_logview_url(metadata.get("logview"), include_access_token=True),
4499
4508
  "project": metadata.get("project"),
4500
4509
  "elapsed_ms": metadata.get("elapsed_ms"),
4501
4510
  "returned_rows": data.get("returned_rows"),
@@ -38,7 +38,7 @@ class ErrorPayload:
38
38
  if self.logview:
39
39
  from .utils import sanitize_logview_url
40
40
 
41
- payload["logview"] = sanitize_logview_url(self.logview)
41
+ payload["logview"] = sanitize_logview_url(self.logview, include_access_token=True)
42
42
  if self.context:
43
43
  payload["context"] = self.context
44
44
  return payload
@@ -186,7 +186,9 @@ class Envelope:
186
186
  data = _normalize_data(self.command, self.data) if normalize else self.data
187
187
  metadata = dict(self.metadata)
188
188
  if metadata.get("logview"):
189
- metadata["logview"] = sanitize_logview_url(metadata["logview"])
189
+ metadata["logview"] = sanitize_logview_url(
190
+ metadata["logview"], include_access_token=True,
191
+ )
190
192
  payload = {
191
193
  "version": self.version,
192
194
  "command": command,
@@ -685,6 +687,9 @@ def _format_next_action(
685
687
  max_rows = data.get("max_rows")
686
688
  if isinstance(max_rows, int) and not isinstance(max_rows, bool) and max_rows > 0:
687
689
  parts.extend(["--max-rows", str(max_rows)])
690
+ cursor = _string_value(data.get("cursor"))
691
+ if cursor:
692
+ parts.extend(["--cursor", _shell_arg(cursor, "<cursor>")])
688
693
  output_path = _string_value(metadata.get("output_path"))
689
694
  output_format = _string_value(metadata.get("output_format"))
690
695
  if output_path:
@@ -4,7 +4,7 @@ import json
4
4
  from typing import TYPE_CHECKING, Any, TextIO
5
5
 
6
6
  from .models import suggested_action_is_safe
7
- from .utils import distribution_cli_text
7
+ from .utils import distribution_cli_text, sanitize_logview_url
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from .models import Envelope, SuggestedAction
@@ -166,9 +166,7 @@ def _render_pending_md(envelope: Envelope) -> str:
166
166
  if metadata.get("wait_seconds") is not None:
167
167
  kv["Waited"] = f"{metadata['wait_seconds']}s"
168
168
  if metadata.get("logview"):
169
- from .utils import sanitize_logview_url
170
-
171
- kv["Logview"] = sanitize_logview_url(metadata["logview"])
169
+ kv["Logview"] = sanitize_logview_url(metadata["logview"], include_access_token=True)
172
170
  if kv:
173
171
  parts.append(render_key_values(kv))
174
172
  parts.append("")
@@ -253,6 +251,18 @@ def render_markdown(envelope: Envelope) -> str:
253
251
  parts.append(
254
252
  f"> **Suggestion**: {distribution_cli_text(err.suggestion)}"
255
253
  )
254
+ details = {}
255
+ metadata = envelope.metadata or {}
256
+ job_id = metadata.get("job_id") or err.instance_id
257
+ if job_id:
258
+ details["Job ID"] = job_id
259
+ if metadata.get("project"):
260
+ details["Project"] = metadata["project"]
261
+ logview = metadata.get("logview") or err.logview
262
+ if logview:
263
+ details["Logview"] = sanitize_logview_url(logview, include_access_token=True)
264
+ if details:
265
+ parts.extend(["", render_key_values(details)])
256
266
  parts.append("")
257
267
  return _append_agent_hints_md(parts, envelope)
258
268
 
@@ -283,6 +293,13 @@ def render_markdown(envelope: Envelope) -> str:
283
293
  if meta_items:
284
294
  parts.append(" | ".join(meta_items))
285
295
  parts.append("")
296
+ details = {}
297
+ if metadata.get("job_id"):
298
+ details["Job ID"] = metadata["job_id"]
299
+ if metadata.get("logview"):
300
+ details["Logview"] = sanitize_logview_url(metadata["logview"], include_access_token=True)
301
+ if details:
302
+ parts.extend([render_key_values(details), ""])
286
303
  rows = data.get("rows")
287
304
  if rows:
288
305
  parts.append(render_table(rows))
@@ -316,8 +316,15 @@ def distribution_cli_text(text: 'str') -> 'str':
316
316
  return "".join(rendered)
317
317
 
318
318
 
319
- def sanitize_logview_url(value: 'str | None') -> 'str | None':
320
- """Remove credentials while retaining a numeric MCQA subquery selector."""
319
+ def sanitize_logview_url(
320
+ value: 'str | None', *, include_access_token: 'bool' = False,
321
+ ) -> 'str | None':
322
+ """Validate LogView URLs, keeping SDK routing and optional instance access.
323
+
324
+ CLI output needs the legacy instance-scoped token to open the link. Audit
325
+ records redact the whole LogView field independently. Other query secrets,
326
+ URL userinfo and fragments are never retained.
327
+ """
321
328
  if not value:
322
329
  return value
323
330
  if any(ord(character) < 32 or ord(character) == 127 for character in value):
@@ -363,13 +370,41 @@ def sanitize_logview_url(value: 'str | None') -> 'str | None':
363
370
  return "[redacted invalid LogView URL]"
364
371
  safe_host = hostname
365
372
  safe_netloc = safe_host if port is None else f"{safe_host}:{port}"
366
- safe_query = urlencode([
373
+ query_items = parse_qsl(parsed.query, keep_blank_values=True)
374
+ routing = dict(query_items)
375
+ allowed_keys = set()
376
+ sdk_path = parsed.path.rstrip("/")
377
+ if (
378
+ (sdk_path == "/logview" or sdk_path.endswith("/job-insights"))
379
+ and len(routing) == len(query_items)
380
+ and all(routing.get(key) for key in ("h", "p", "i"))
381
+ ):
382
+ try:
383
+ endpoint = urlsplit(routing["h"])
384
+ valid_endpoint = (
385
+ endpoint.scheme in {"http", "https"}
386
+ and bool(endpoint.hostname)
387
+ and endpoint.username is None
388
+ and endpoint.password is None
389
+ and not endpoint.query
390
+ and not endpoint.fragment
391
+ and sanitize_logview_url(routing["h"]) == routing["h"]
392
+ )
393
+ except ValueError:
394
+ valid_endpoint = False
395
+ if valid_endpoint:
396
+ allowed_keys.update({"h", "p", "i"})
397
+ if sdk_path == "/logview" and include_access_token:
398
+ allowed_keys.add("token")
399
+ safe_items = [
367
400
  (key, item)
368
- for key, item in parse_qsl(parsed.query, keep_blank_values=True)
369
- if key.lower() == "subquery" and re.fullmatch(r"[0-9]+", item)
370
- ])
401
+ for key, item in query_items
402
+ if key in allowed_keys
403
+ or (key.lower() == "subquery" and re.fullmatch(r"[0-9]+", item))
404
+ ]
405
+ safe_query = urlencode(safe_items)
371
406
  if (
372
- safe_query == parsed.query
407
+ safe_items == query_items
373
408
  and not parsed.fragment
374
409
  and safe_netloc == parsed.netloc
375
410
  ):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.8.1
3
+ Version: 0.8.2
4
4
  Summary: Agent-native MaxCompute CLI for external coding agents
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -189,6 +189,48 @@ def test_safe_logview_preserves_valid_explicit_port():
189
189
  )
190
190
 
191
191
 
192
+ def test_logview_output_keeps_legacy_access_and_job_insights_routing():
193
+ from maxc_cli.backend.job import JobMixin
194
+ from maxc_cli.models import Envelope
195
+
196
+ for url in (
197
+ "http://logview.odps.aliyun.com/logview/"
198
+ "?h=https://service.example.test/api&p=test_proj&i=job-1&token=fixture-access&subQuery=7",
199
+ "https://maxcompute.console.aliyun.com/cn-hangzhou/job-insights"
200
+ "?h=https://service.example.test/api&p=test_proj&i=job-1&subQuery=7",
201
+ ):
202
+ instance = MagicMock()
203
+ instance.get_logview_address.return_value = url
204
+ address = JobMixin._safe_logview(object(), instance)
205
+ assert address == url
206
+ assert Envelope(command="job.status", status="success", metadata={"logview": address}).to_dict()["metadata"]["logview"] == url
207
+
208
+
209
+ def test_logview_output_drops_unrelated_secrets_and_default_still_redacts_token():
210
+ from maxc_cli.models import Envelope
211
+ from maxc_cli.utils import sanitize_logview_url
212
+
213
+ routing = "h=https://service.example.test/api&p=test_proj&i=job-1"
214
+ url = f"https://logview.example.test/logview/?{routing}&token=fixture-access&secret=other-secret#fragment"
215
+ payload = Envelope(command="query", status="success", metadata={"logview": url}).to_dict()
216
+ assert payload["metadata"]["logview"].endswith("&token=fixture-access")
217
+ assert "other-secret" not in payload["metadata"]["logview"]
218
+ assert "fragment" not in payload["metadata"]["logview"]
219
+ assert "fixture-access" not in sanitize_logview_url(url)
220
+
221
+
222
+ @pytest.mark.parametrize("routing", [
223
+ "h=https://service.example.test/api&p=test_proj",
224
+ "h=https://user:password@service.example.test/api&p=test_proj&i=job-1",
225
+ "h=https://service.example.test/api?secret=hidden&p=test_proj&i=job-1",
226
+ ])
227
+ def test_logview_output_never_preserves_token_without_safe_complete_routing(routing):
228
+ from maxc_cli.utils import sanitize_logview_url
229
+
230
+ url = f"https://logview.example.test/logview/?{routing}&token=fixture-access"
231
+ assert "fixture-access" not in sanitize_logview_url(url, include_access_token=True)
232
+
233
+
192
234
  def test_polling_continues_after_fewer_than_5_consecutive_errors() -> None:
193
235
  """4 consecutive reload failures should not raise — polling continues."""
194
236
  instance = FakeInstance(reload_errors=4)
@@ -463,3 +505,13 @@ def test_job_result_rejects_cursor_from_another_local_job(tmp_path: Path) -> Non
463
505
 
464
506
  with pytest.raises(ValidationError, match="different job"):
465
507
  app.job_result(second_job, max_rows=2, cursor=cursor)
508
+
509
+
510
+ @pytest.mark.parametrize("endpoint", ["https://service.example.test:bad/api", "https://service.example.test:99999/api", "https://service.example.test:0/api", "https://-invalid.example.test/api"])
511
+ def test_logview_rejects_invalid_nested_endpoint_authority(endpoint):
512
+ from urllib.parse import urlencode
513
+
514
+ from maxc_cli.utils import sanitize_logview_url
515
+
516
+ url = "https://logview.example.test/logview/?" + urlencode({"h": endpoint, "p": "proj", "i": "job-1", "token": "fixture-access"})
517
+ assert "fixture-access" not in sanitize_logview_url(url, include_access_token=True)
@@ -207,3 +207,28 @@ def test_default_human_failure_does_not_render_gated_next_actions(
207
207
 
208
208
  assert "Next actions" not in rendered
209
209
  assert action.command not in rendered
210
+
211
+
212
+ @pytest.mark.parametrize("status", ["pending", "success", "failure"])
213
+ @pytest.mark.parametrize("renderer", [_render_human, render_markdown])
214
+ def test_query_renderers_keep_job_scope_and_usable_logview(status, renderer):
215
+ from maxc_cli.exceptions import ErrorPayload
216
+
217
+ url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=proj&i=job-42&token=fixture-access"
218
+ error = ErrorPayload(code="SQL_ERROR", message="failed", suggestion=None, recoverable=False, instance_id="job-42", logview=url) if status == "failure" else None
219
+ envelope = Envelope(command="query", status=status, data={"job_id": "job-42", "rows": [], "total_rows": 0}, metadata={"job_id": "job-42", "project": "proj", "logview": url}, error=error)
220
+ rendered = renderer(envelope)
221
+ assert "job-42" in rendered
222
+ assert url in rendered
223
+ if status == "failure":
224
+ assert envelope.to_dict()["error"]["logview"] == url
225
+
226
+
227
+ def test_error_logview_retains_access_but_audit_redacts_it():
228
+ from maxc_cli.audit import sanitize_audit_payload
229
+ from maxc_cli.exceptions import SqlError
230
+
231
+ url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=proj&i=job-42&token=fixture-access"
232
+ payload = SqlError("failed", logview=url).to_payload().to_dict()
233
+ assert payload["logview"] == url
234
+ assert "fixture-access" not in str(sanitize_audit_payload(payload))
@@ -862,3 +862,18 @@ def test_job_result_output_format_requires_output_before_remote_fetch(
862
862
  assert code == 2
863
863
  assert payload["error"]["code"] == "OUTPUT_FORMAT_ERROR"
864
864
  assert app.job_result_calls == 0
865
+
866
+
867
+
868
+ def test_raised_query_failure_keeps_usable_logview_on_stderr(tmp_path, monkeypatch):
869
+ url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=test_project&i=job-42&token=fixture-access"
870
+
871
+ class Raising(_FakeCliApp):
872
+ def query(self, **_kwargs):
873
+ raise SqlError("invalid SQL", instance_id="job-42", logview=url)
874
+
875
+ code, stdout, stderr = _run_with_app(tmp_path, monkeypatch, Raising(), ["query", "SELECT 1"])
876
+ assert code != 0
877
+ assert stdout == ""
878
+ assert "job-42" in stderr
879
+ assert url in stderr
@@ -17,6 +17,7 @@ def _make_app(tmp_path: Path) -> MaxCApp:
17
17
  config_path.write_text("backend:\n type: auto\n")
18
18
  app = MaxCApp(cwd=tmp_path, config_path=config_path, load_backend=False)
19
19
  app.config.state_dir = tmp_path / "state"
20
+ app.config.cache_dir = tmp_path / "cache"
20
21
  # Simulate remote_jobs=True
21
22
  app.remote_jobs = True # remote_jobs is a plain instance attribute set in __init__
22
23
  return app
@@ -98,6 +99,48 @@ def test_query_returns_success_when_job_finishes_within_wait(tmp_path):
98
99
  assert kwargs["poll_interval"] == 1
99
100
 
100
101
 
102
+ @pytest.mark.parametrize("wait", [0, 10])
103
+ def test_query_response_uses_current_lifecycle_and_complete_logview(tmp_path, wait):
104
+ from types import SimpleNamespace
105
+ from urllib.parse import parse_qs, urlsplit
106
+
107
+ from maxc_cli.audit import sanitize_audit_payload
108
+ from maxc_cli.backend.job import JobMixin
109
+ from maxc_cli.backend.query import QueryMixin
110
+
111
+ url = (
112
+ "http://logview.odps.aliyun.com/logview/"
113
+ "?h=https%3A%2F%2Fservice.cn-hangzhou.maxcompute.aliyun.com%2Fapi"
114
+ "&p=test_proj&i=job-1&token=fixture-logview-access"
115
+ )
116
+ instance = SimpleNamespace(id="job-1", get_logview_address=lambda: url)
117
+ app = _make_app(tmp_path)
118
+ app.backend = MagicMock()
119
+ app.backend.client.run_sql.return_value = instance
120
+ app.backend._safe_logview.side_effect = lambda inst: JobMixin._safe_logview(object(), inst)
121
+ app.backend.submit_query.side_effect = lambda *args, **kwargs: QueryMixin.submit_query(
122
+ app.backend, *args, **kwargs,
123
+ )
124
+ done = _fake_job_info(status="success")
125
+ done.logview = JobMixin._safe_logview(object(), instance)
126
+ app.backend.wait_job.return_value = done
127
+ app.backend.fetch_job_result.return_value = _fake_query_result()
128
+
129
+ payload = app.query(command="query", sql="SELECT 1", project="test_proj", wait=wait).to_dict()
130
+
131
+ assert payload["status"] == ("pending" if wait == 0 else "success")
132
+ hints = payload["agent_hints"] or {}
133
+ assert not any("job.status or job.wait" in item for item in hints.get("warnings", []))
134
+ action_ids = hints.get("action_ids", [])
135
+ if wait == 0:
136
+ assert "job.wait" in action_ids
137
+ else:
138
+ assert "job.wait" not in action_ids and "job.status" not in action_ids
139
+ params = parse_qs(urlsplit(payload["metadata"]["logview"]).query)
140
+ assert params == parse_qs(urlsplit(url).query)
141
+ assert "fixture-logview-access" not in str(sanitize_audit_payload(payload))
142
+
143
+
101
144
  def test_query_returns_successful_statement_envelope_for_ddl(tmp_path):
102
145
  app = _make_app(tmp_path)
103
146
  job_done = _fake_job_info(status="success")
@@ -445,3 +488,56 @@ def test_error_payload_keeps_only_safe_numeric_logview_selector() -> None:
445
488
  assert payload["logview"] == (
446
489
  "https://logview.example.test/job-1?subQuery=9"
447
490
  )
491
+
492
+
493
+ @pytest.mark.parametrize("command", ["query", "job.wait", "job.result"])
494
+ def test_completed_result_fetch_failure_recovers_by_reading_same_job(tmp_path, command):
495
+ app = _make_app(tmp_path)
496
+ done = _fake_job_info(status="success")
497
+ done.logview = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=test_proj&i=job-1&token=fixture-access"
498
+ app.backend = MagicMock()
499
+ app.backend.submit_query.return_value = _fake_job_info()
500
+ app.backend.get_job.return_value = done
501
+ app.backend.wait_job.return_value = done
502
+ app.backend.fetch_job_result.side_effect = BackendConnectionError("result reader unavailable")
503
+ if command == "query":
504
+ envelope = app.query(command=command, sql="SELECT 1", project="test_proj", max_rows=2)
505
+ elif command == "job.wait":
506
+ envelope, events = app.job_wait("job-1", project="test_proj")
507
+ assert events == []
508
+ else:
509
+ envelope = app.job_result("job-1", project="test_proj", max_rows=2)
510
+ payload = envelope.to_dict()
511
+ assert payload["status"] == "failure"
512
+ assert payload["error"]["code"] == "BACKEND_CONNECTION_ERROR"
513
+ assert payload["metadata"]["logview"] == done.logview
514
+ assert payload["metadata"]["job_status"] == "success"
515
+ actions = {item.id: item.command for item in envelope.agent_hints.actions}
516
+ assert "job.result" in actions
517
+ assert "job.wait" not in actions and "job.status" not in actions
518
+ if command != "job.wait":
519
+ assert "--max-rows 2" in actions["job.result"]
520
+ app.backend.submit_query.assert_called_once() if command == "query" else app.backend.submit_query.assert_not_called()
521
+
522
+
523
+ @pytest.mark.parametrize("command", ["query", "job.result"])
524
+ def test_result_fetch_failure_preserves_failed_cursor(tmp_path, command):
525
+ from maxc_cli.utils import encode_cursor
526
+
527
+ app = _make_app(tmp_path)
528
+ session = app.cache.create_session(job_id="job-1", project="test_proj", sql="SELECT 1")
529
+ cursor = encode_cursor(2, session_id=session)
530
+ done = _fake_job_info(status="success")
531
+ app.backend = MagicMock()
532
+ app.backend.get_job.return_value = done
533
+ app.backend.fetch_job_result.side_effect = BackendConnectionError("unavailable", logview=done.logview)
534
+ if command == "query":
535
+ envelope = app.query(command=command, sql="SELECT 1", cursor=cursor, max_rows=2, project="test_proj")
536
+ else:
537
+ envelope = app.job_result("job-1", cursor=cursor, max_rows=2, project="test_proj")
538
+ assert envelope.status == "failure"
539
+ assert envelope.metadata["job_id"] == "job-1"
540
+ retry = next(item for item in envelope.agent_hints.actions if item.id == "job.result")
541
+ assert "--cursor " + cursor in retry.command
542
+ assert "--max-rows 2" in retry.command
543
+ app.backend.submit_query.assert_not_called()
@@ -62,6 +62,7 @@ class _StubBackend:
62
62
  project = "test_project"
63
63
  _instance_to_query_result = OdpsBackend._instance_to_query_result
64
64
  _safe_task_results = JobMixin._safe_task_results
65
+ _safe_logview = JobMixin._safe_logview
65
66
  _task_cost = OdpsBackend._task_cost
66
67
 
67
68
 
@@ -517,8 +518,7 @@ def test_degraded_offset_and_max_rows_apply_to_task_rows() -> None:
517
518
  offset=1,
518
519
  )
519
520
  assert [row["task_name"] for row in result.rows] == ["taskB"]
520
- # total_rows counts the materialized window, so pagination terminates.
521
- assert result.total_rows == 1
521
+ assert result.total_rows == 2
522
522
  assert result.has_more is False
523
523
 
524
524
 
@@ -603,3 +603,27 @@ def test_fallback_warning_is_still_emitted_to_stderr(capsys) -> None:
603
603
 
604
604
  captured = capsys.readouterr()
605
605
  assert "Instance tunnel timed out" in captured.err
606
+
607
+
608
+ @pytest.mark.parametrize("offset, expected, has_more", [(0, ["1", "2"], True), (2, ["3", "4"], False), (8, [], False)])
609
+ def test_csv_fallback_reports_all_accessible_rows(offset, expected, has_more):
610
+ result = _run(CsvRecordReader(schema=None, stream="a\n1\n2\n3\n4\n"), max_rows=2, offset=offset)
611
+ assert [row["a"] for row in result.rows] == expected
612
+ assert result.total_rows == 4
613
+ assert result.has_more is has_more
614
+
615
+
616
+ def test_raw_task_fallback_first_page_preserves_remaining_tasks():
617
+ raw = "a,b\n1,2,3\n"
618
+ result = _run(CsvRecordReader(schema=None, stream=raw), task_results={"taskA": raw, "taskB": raw}, max_rows=1)
619
+ assert result.total_rows == 2
620
+ assert result.has_more is True
621
+
622
+
623
+
624
+ def test_result_conversion_keeps_logview_for_cursor_responses():
625
+ url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=test_project&i=fake_instance_id&token=fixture-access"
626
+ instance = _FakeInstance(CsvRecordReader(schema=None, stream="a\n1\n2\n"))
627
+ instance.get_logview_address = lambda: url
628
+ result = _StubBackend()._instance_to_query_result(instance, project="test_project", max_rows=1, offset=1, sql="SELECT a FROM t", elapsed_ms=0)
629
+ assert result.extra_metadata["logview"] == url
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes