cassis-cli 1.6.0__tar.gz → 1.7.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cassis-cli
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: Validate, test and evaluate your Cassis ontology from your terminal, then publish it
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -132,6 +132,11 @@ cassis schema push schema.sql
132
132
  # List the projects the API key can reach (id, name, published version, dialect):
133
133
  cassis projects list
134
134
 
135
+ # Refresh the issues from the conversations nobody has analyzed yet (the same pass as the
136
+ # webapp's "Analyze conversations" button; waits for the result, --no-wait returns at once,
137
+ # and Ctrl-C cancels the run server-side and exits 130):
138
+ cassis issues analyze
139
+
135
140
  # Triage the issues Cassis raised (filter by --status/--impact/--cause; --json for raw output):
136
141
  cassis issues list --status open
137
142
  cassis issues show 019f0000-0000-7000-8000-0000000000e1
@@ -190,9 +195,9 @@ cassis ontology fmt --check
190
195
  | Code | Meaning |
191
196
  | ---- | ------------------------------------------------------------------------------ |
192
197
  | 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
193
- | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
198
+ | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
194
199
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
195
- | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run already active, out of credits, or `--timeout` reached |
200
+ | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
196
201
 
197
202
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
198
203
  20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
@@ -110,6 +110,11 @@ cassis schema push schema.sql
110
110
  # List the projects the API key can reach (id, name, published version, dialect):
111
111
  cassis projects list
112
112
 
113
+ # Refresh the issues from the conversations nobody has analyzed yet (the same pass as the
114
+ # webapp's "Analyze conversations" button; waits for the result, --no-wait returns at once,
115
+ # and Ctrl-C cancels the run server-side and exits 130):
116
+ cassis issues analyze
117
+
113
118
  # Triage the issues Cassis raised (filter by --status/--impact/--cause; --json for raw output):
114
119
  cassis issues list --status open
115
120
  cassis issues show 019f0000-0000-7000-8000-0000000000e1
@@ -168,9 +173,9 @@ cassis ontology fmt --check
168
173
  | Code | Meaning |
169
174
  | ---- | ------------------------------------------------------------------------------ |
170
175
  | 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
171
- | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
176
+ | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
172
177
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
173
- | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run already active, out of credits, or `--timeout` reached |
178
+ | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
174
179
 
175
180
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
176
181
  20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
@@ -635,7 +635,11 @@ def post_eval_run_cancel(
635
635
 
636
636
 
637
637
  class IssueNotFoundError(ApiError):
638
- """The project has no issue (or occurrence) with this id."""
638
+ """The project has no issue, occurrence or issue-analysis run with this id.
639
+
640
+ Raised off the server's exact 404 detail, so it separates "this id doesn't
641
+ exist" from "this project isn't in scope for your key", which 404s too.
642
+ """
639
643
 
640
644
 
641
645
  def _get_issue_json(
@@ -645,8 +649,14 @@ def _get_issue_json(
645
649
  *,
646
650
  params: Optional[dict[str, str]] = None,
647
651
  not_found_message: Optional[str] = None,
652
+ not_found_detail: str = "Issue not found",
648
653
  ) -> Any:
649
- """GET an issues URL with the shared error mapping."""
654
+ """GET an issues URL with the shared error mapping.
655
+
656
+ `not_found_detail` is the server's exact 404 detail for "this id does not
657
+ exist in the project" — `"Issue not found"` for issues and occurrences,
658
+ `"Issue-analysis run not found"` for analysis runs.
659
+ """
650
660
  try:
651
661
  with _client(transport=transport) as client:
652
662
  response = client.get(url, params=params, headers={"Authorization": f"Bearer {api_key}"})
@@ -657,8 +667,8 @@ def _get_issue_json(
657
667
  raise AuthError("The Cassis API rejected the API key (invalid or expired).")
658
668
  # Exact-match wire contract with the issue endpoints' 404 detail (see the
659
669
  # issue routes in backend/app/endpoints/ci.py): it distinguishes a missing
660
- # issue or occurrence (exit 1) from a project-scope 404 (exit 3).
661
- if response.status_code == 404 and not_found_message and _detail_or_text(response) == "Issue not found":
670
+ # issue, occurrence or run (exit 1) from a project-scope 404 (exit 3).
671
+ if response.status_code == 404 and not_found_message and _detail_or_text(response) == not_found_detail:
662
672
  raise IssueNotFoundError(not_found_message)
663
673
  if response.status_code in (403, 404):
664
674
  raise _project_scope_error(response)
@@ -925,3 +935,102 @@ def post_ontology_test(
925
935
  if not isinstance(result, dict) or "status" not in result:
926
936
  raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
927
937
  return result
938
+
939
+
940
+ class IssueAnalysisActiveError(ApiError):
941
+ """An issue-analysis run is already in flight for the project (HTTP 409)."""
942
+
943
+
944
+ class NothingToAnalyzeError(ApiError):
945
+ """Every conversation on the project has already been analyzed (HTTP 422)."""
946
+
947
+
948
+ _ANALYSIS_RUN_KEYS = ("id", "status", "total_chats", "chats_analyzed", "occurrences_created", "issues_touched")
949
+
950
+
951
+ def _check_analysis_run_shape(result: Any, url: str) -> dict[str, Any]:
952
+ if not isinstance(result, dict) or not all(key in result for key in _ANALYSIS_RUN_KEYS):
953
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
954
+ return result
955
+
956
+
957
+ def post_issue_analysis_start(
958
+ *,
959
+ api_url: str,
960
+ api_key: str,
961
+ project_id: str,
962
+ transport: Optional[httpx.BaseTransport] = None,
963
+ ) -> dict[str, Any]:
964
+ """POST /api/ci/projects/{project_id}/issue-analysis/runs and return the run record."""
965
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs"
966
+ try:
967
+ with _client(transport=transport) as client:
968
+ response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
969
+ except httpx.HTTPError as exc:
970
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
971
+
972
+ if response.status_code == 401:
973
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
974
+ if response.status_code == 409:
975
+ raise IssueAnalysisActiveError(
976
+ "An issue analysis is already running for this project — wait for it to finish, "
977
+ "or cancel it from the webapp's Issues page."
978
+ )
979
+ if response.status_code == 422 and "analyz" in response.text.lower():
980
+ # The route takes no body, so its 422 is the server's "nothing to
981
+ # analyze" answer; any other 422 falls through to the generic error.
982
+ raise NothingToAnalyzeError("Nothing to analyze: every conversation on this project has already been analyzed.")
983
+ if response.status_code in (403, 404):
984
+ raise _project_scope_error(response)
985
+ if response.status_code >= 400:
986
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
987
+ return _check_analysis_run_shape(_parse_json_response(response, url), url)
988
+
989
+
990
+ def get_issue_analysis_run(
991
+ *,
992
+ api_url: str,
993
+ api_key: str,
994
+ project_id: str,
995
+ run_id: str,
996
+ transport: Optional[httpx.BaseTransport] = None,
997
+ ) -> dict[str, Any]:
998
+ """GET /api/ci/projects/{project_id}/issue-analysis/runs/{run_id} and return the run record."""
999
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs/{run_id}"
1000
+ return _check_analysis_run_shape(
1001
+ _get_issue_json(
1002
+ url,
1003
+ api_key,
1004
+ transport,
1005
+ not_found_message=f"No issue-analysis run {run_id} in this project.",
1006
+ not_found_detail="Issue-analysis run not found",
1007
+ ),
1008
+ url,
1009
+ )
1010
+
1011
+
1012
+ def post_issue_analysis_cancel(
1013
+ *,
1014
+ api_url: str,
1015
+ api_key: str,
1016
+ project_id: str,
1017
+ run_id: str,
1018
+ transport: Optional[httpx.BaseTransport] = None,
1019
+ ) -> None:
1020
+ """POST /api/ci/projects/{project_id}/issue-analysis/runs/{run_id}/cancel."""
1021
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs/{run_id}/cancel"
1022
+ try:
1023
+ with _client(transport=transport) as client:
1024
+ response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
1025
+ except httpx.HTTPError as exc:
1026
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
1027
+ if response.status_code == 401:
1028
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
1029
+ # Same exact-match contract as `get_issue_analysis_run`: a missing run is
1030
+ # not a project-scope problem.
1031
+ if response.status_code == 404 and _detail_or_text(response) == "Issue-analysis run not found":
1032
+ raise IssueNotFoundError(f"No issue-analysis run {run_id} in this project.")
1033
+ if response.status_code in (403, 404):
1034
+ raise _project_scope_error(response)
1035
+ if response.status_code >= 400:
1036
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
@@ -20,6 +20,9 @@ EXIT_OK = 0
20
20
  EXIT_VALIDATION_FAILED = 1
21
21
  EXIT_USAGE = 2
22
22
  EXIT_TRANSPORT = 3
23
+ # Conventional "terminated by SIGINT" code: the user pressed Ctrl-C while a
24
+ # command was waiting on a server-side run (which the command cancels first).
25
+ EXIT_INTERRUPTED = 130
23
26
 
24
27
  # Request ceilings of the /api/ci file-tree endpoints, mirrored so oversized
25
28
  # trees fail fast with a clear message before any upload. Sized for ~10,000
@@ -30,6 +30,7 @@ from cassis_cli.api import (
30
30
  )
31
31
  from cassis_cli.common import (
32
32
  DEFAULT_BASE_PATH,
33
+ EXIT_INTERRUPTED,
33
34
  EXIT_OK,
34
35
  EXIT_TRANSPORT,
35
36
  EXIT_USAGE,
@@ -41,7 +42,6 @@ from cassis_cli.common import (
41
42
 
42
43
  app = typer.Typer(no_args_is_help=True, help="Eval commands.")
43
44
 
44
- EXIT_INTERRUPTED = 130
45
45
 
46
46
  # Result statuses that count as "passed"; everything else is a failure or error.
47
47
  _PASSED = "passed"
@@ -134,6 +134,9 @@ def _print_results_table(results: list[dict[str, Any]]) -> None:
134
134
 
135
135
  def _print_summary(run: dict[str, Any]) -> None:
136
136
  summary = run.get("summary") or {}
137
+ if summary.get("error"):
138
+ # A failed run carries its reason here (e.g. the worker was lost).
139
+ typer.secho(f"Run error: {str(summary['error'])[:300]}", fg=typer.colors.RED)
137
140
  total = summary.get("total", run.get("total_cases"))
138
141
  passed = summary.get("passed", 0)
139
142
  accuracy = summary.get("accuracy")
@@ -498,7 +501,8 @@ def run(
498
501
  typer.echo(f"Follow it at: {run_url}")
499
502
  raise typer.Exit(EXIT_OK)
500
503
 
501
- typer.echo(f"Eval run {run_id} started: {total} cases against {ontology_label!r}.")
504
+ # To stderr under --json so `cassis eval run --json | jq` gets only the record.
505
+ typer.echo(f"Eval run {run_id} started: {total} cases against {ontology_label!r}.", err=json_output)
502
506
 
503
507
  try:
504
508
  final_run, results = _wait_for_run(
@@ -509,13 +513,15 @@ def run(
509
513
  total=total,
510
514
  poll_interval=poll_interval,
511
515
  timeout=timeout,
516
+ json_output=json_output,
512
517
  )
513
518
  except KeyboardInterrupt:
514
- typer.echo("")
515
- typer.echo("Interrupted — cancelling the run...")
519
+ # To stderr under --json, for the same reason as `issues analyze`.
520
+ typer.echo("", err=json_output)
521
+ typer.echo("Interrupted — cancelling the run...", err=json_output)
516
522
  try:
517
523
  post_eval_run_cancel(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
518
- typer.echo("Run cancelled.")
524
+ typer.echo("Run cancelled.", err=json_output)
519
525
  except ApiError as exc:
520
526
  typer.secho(f"Could not cancel the run: {exc}", fg=typer.colors.RED, err=True)
521
527
  raise typer.Exit(EXIT_INTERRUPTED)
@@ -550,9 +556,12 @@ def _wait_for_run(
550
556
  total: int,
551
557
  poll_interval: float,
552
558
  timeout: float,
559
+ json_output: bool = False,
553
560
  ) -> "tuple[dict[str, Any], list[dict[str, Any]]]":
554
561
  """Poll the run until terminal. Returns (run, results).
555
562
 
563
+ Progress lines go to stderr under --json so stdout stays the JSON record.
564
+
556
565
  Exits 3 directly on timeout, on an auth failure (fail fast — retrying a
557
566
  revoked key can't succeed), or after several consecutive poll failures.
558
567
  The server-side run keeps going in all three cases.
@@ -587,7 +596,7 @@ def _wait_for_run(
587
596
  passed = sum(1 for r in results if r.get("status") == _PASSED)
588
597
  line = f"{done}/{total} cases done — {passed} ✓ {done - passed} ✗"
589
598
  if line != last_line:
590
- typer.echo(line)
599
+ typer.echo(line, err=json_output)
591
600
  last_line = line
592
601
 
593
602
  if run_record.get("status") in _TERMINAL_RUN_STATUSES:
@@ -31,7 +31,7 @@ GUIDE_FILENAME = "AGENTS.md"
31
31
  # Monotonic version of the doctrine text below. Bump it whenever
32
32
  # ontology_design_guide.md changes (a backend test enforces the pairing) — it
33
33
  # is what lets an older writer recognize a newer guide and leave it alone.
34
- DOCTRINE_VERSION = 5
34
+ DOCTRINE_VERSION = 6
35
35
 
36
36
  # Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
37
37
  # the server-side git export writes the same file, and differing banners would
@@ -3,12 +3,14 @@
3
3
  Issues are what the product found wrong while answering questions (an ontology
4
4
  gap, missing data). Listing, reading the evidence behind an occurrence, and
5
5
  resolving or dismissing them from a checkout keeps the fix loop next to the
6
- ontology files instead of in the webapp.
6
+ ontology files instead of in the webapp; `analyze` refreshes them from the
7
+ conversations that arrived since the last pass, without waiting for the nightly one.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
10
11
 
11
12
  import json
13
+ import time
12
14
  from pathlib import Path
13
15
  from typing import Any, Optional
14
16
 
@@ -18,14 +20,20 @@ from cassis_cli.api import (
18
20
  ApiError,
19
21
  AuthError,
20
22
  IssueNotFoundError,
23
+ NothingToAnalyzeError,
21
24
  get_issue,
25
+ get_issue_analysis_run,
22
26
  get_issue_evidence,
23
27
  get_issues,
28
+ post_issue_analysis_cancel,
29
+ post_issue_analysis_start,
24
30
  post_issue_status,
25
31
  )
26
32
  from cassis_cli.common import (
27
33
  DEFAULT_BASE_PATH,
34
+ EXIT_INTERRUPTED,
28
35
  EXIT_OK,
36
+ EXIT_TRANSPORT,
29
37
  EXIT_USAGE,
30
38
  EXIT_VALIDATION_FAILED,
31
39
  api_failure,
@@ -288,7 +296,7 @@ def resolve(
288
296
  api_url: str = _API_URL_OPTION,
289
297
  base_path: str = _BASE_PATH_OPTION,
290
298
  ) -> None:
291
- """Mark an issue resolved — the ontology change that fixes it has landed.
299
+ """Mark an issue resolved — the ontology change that fixes it is published.
292
300
 
293
301
  Exits 0 on success, 1 when the issue does not exist in the project, 2 on
294
302
  usage errors, 3 on transport/API errors.
@@ -352,3 +360,178 @@ def reopen(
352
360
  api_url=api_url,
353
361
  base_path=base_path,
354
362
  )
363
+
364
+
365
+ _TERMINAL_ANALYSIS_STATUSES = frozenset({"completed", "failed", "cancelled"})
366
+
367
+ # Consecutive poll failures tolerated before giving up — same tolerance as
368
+ # `eval run`: a transient blip must not abandon a multi-minute run, a
369
+ # permanently broken poll must not spin until --timeout.
370
+ _MAX_CONSECUTIVE_POLL_FAILURES = 5
371
+
372
+
373
+ @app.command()
374
+ def analyze(
375
+ path: Path = _PATH_OPTION,
376
+ project_id: Optional[str] = _PROJECT_OPTION,
377
+ api_key: Optional[str] = _API_KEY_OPTION,
378
+ api_url: str = _API_URL_OPTION,
379
+ base_path: str = _BASE_PATH_OPTION,
380
+ wait: bool = typer.Option(
381
+ True,
382
+ "--wait/--no-wait",
383
+ help="Wait for the analysis to finish and print its summary (default), or just print the run id.",
384
+ ),
385
+ poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between status polls."),
386
+ timeout: float = typer.Option(1800.0, "--timeout", help="Give up waiting after this many seconds."),
387
+ json_output: bool = typer.Option(False, "--json", help="Print the final run record as raw JSON."),
388
+ ) -> None:
389
+ """Analyze the conversations nobody has analyzed yet, turning what went wrong into issues.
390
+
391
+ The same pass as the webapp's "Analyze conversations" button — it runs on
392
+ Cassis's workers, so a Ctrl-C or a lost connection never kills it — started
393
+ on demand instead of waiting for the nightly one. When every conversation is
394
+ already analyzed the command is a no-op and exits 0, so a job re-running it
395
+ on a quiet project stays green. Exits 0 when the run completes, 1 when it
396
+ fails or is cancelled, 2 on usage errors, 3 on transport errors, when a run
397
+ is already in flight, or on --timeout (the run keeps going server-side).
398
+ Ctrl-C cancels the run and exits 130.
399
+ """
400
+ api_key = require_api_key(api_key)
401
+ project_id = resolve_project_id(project_id, path / Path(base_path), quiet=json_output)
402
+
403
+ try:
404
+ run = post_issue_analysis_start(api_url=api_url, api_key=api_key, project_id=project_id)
405
+ except NothingToAnalyzeError as exc:
406
+ if json_output:
407
+ typer.echo(json.dumps({"run": None, "message": str(exc)}, indent=2))
408
+ else:
409
+ typer.echo(str(exc))
410
+ raise typer.Exit(EXIT_OK) from exc
411
+ except (AuthError, ApiError) as exc: # covers IssueAnalysisActiveError too
412
+ raise api_failure(exc) from exc
413
+
414
+ run_id = str(run["id"])
415
+ total = int(run.get("total_chats") or 0)
416
+
417
+ if not wait:
418
+ if json_output:
419
+ typer.echo(json.dumps({"run": run}, indent=2))
420
+ else:
421
+ typer.echo(f"Analysis run {run_id} started: {total} conversation(s) to analyze.")
422
+ typer.echo("It keeps going server-side; the results land on the webapp's Issues page.")
423
+ raise typer.Exit(EXIT_OK)
424
+
425
+ if not json_output:
426
+ typer.echo(f"Analysis run {run_id} started: {total} conversation(s) to analyze.")
427
+
428
+ try:
429
+ final_run = _wait_for_analysis(
430
+ api_url=api_url,
431
+ api_key=api_key,
432
+ project_id=project_id,
433
+ run_id=run_id,
434
+ poll_interval=poll_interval,
435
+ timeout=timeout,
436
+ quiet=json_output,
437
+ )
438
+ except KeyboardInterrupt:
439
+ # To stderr under --json: stdout is the machine's, and these lines would
440
+ # land in front of the JSON document a caller is parsing.
441
+ typer.echo("", err=json_output)
442
+ typer.echo("Interrupted — cancelling the analysis...", err=json_output)
443
+ try:
444
+ post_issue_analysis_cancel(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
445
+ typer.echo("Analysis cancelled. Conversations analyzed so far keep their occurrences.", err=json_output)
446
+ except ApiError as exc:
447
+ typer.secho(f"Could not cancel the analysis: {exc}", fg=typer.colors.RED, err=True)
448
+ raise typer.Exit(EXIT_INTERRUPTED)
449
+
450
+ if json_output:
451
+ typer.echo(json.dumps({"run": final_run}, indent=2))
452
+ else:
453
+ _print_analysis_outcome(final_run)
454
+
455
+ if final_run.get("status") == "completed":
456
+ raise typer.Exit(EXIT_OK)
457
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
458
+
459
+
460
+ def _wait_for_analysis(
461
+ *,
462
+ api_url: str,
463
+ api_key: str,
464
+ project_id: str,
465
+ run_id: str,
466
+ poll_interval: float,
467
+ timeout: float,
468
+ quiet: bool,
469
+ ) -> dict[str, Any]:
470
+ """Poll the run until terminal and return its final record.
471
+
472
+ Exits 3 directly on timeout, on an auth failure (fail fast — retrying a
473
+ revoked key can't succeed), or after several consecutive poll failures.
474
+ The server-side run keeps going in all three cases.
475
+ """
476
+ deadline = time.monotonic() + timeout
477
+ last_line = ""
478
+ failures = 0
479
+ while time.monotonic() < deadline:
480
+ try:
481
+ run = get_issue_analysis_run(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
482
+ except AuthError as exc:
483
+ typer.secho(f"{exc} The analysis keeps going server-side.", fg=typer.colors.RED, err=True)
484
+ raise typer.Exit(EXIT_TRANSPORT) from exc
485
+ except IssueNotFoundError as exc:
486
+ # The run is gone (purged): nothing to keep polling for.
487
+ raise _not_found_failure(exc) from exc
488
+ except ApiError as exc:
489
+ failures += 1
490
+ if failures >= _MAX_CONSECUTIVE_POLL_FAILURES:
491
+ typer.secho(
492
+ f"Polling failed {failures} times in a row ({exc}). "
493
+ "Giving up — the analysis keeps going server-side; see the webapp's Issues page.",
494
+ fg=typer.colors.RED,
495
+ err=True,
496
+ )
497
+ raise typer.Exit(EXIT_TRANSPORT) from exc
498
+ typer.secho(f"(poll failed, retrying: {exc})", fg=typer.colors.YELLOW, err=True)
499
+ time.sleep(poll_interval)
500
+ continue
501
+ failures = 0
502
+
503
+ line = (
504
+ f"{run.get('chats_analyzed', 0)}/{run.get('total_chats', 0)} conversations analyzed — "
505
+ f"{run.get('occurrences_created', 0)} occurrence(s) found"
506
+ )
507
+ if not quiet and line != last_line:
508
+ typer.echo(line)
509
+ last_line = line
510
+
511
+ if run.get("status") in _TERMINAL_ANALYSIS_STATUSES:
512
+ return run
513
+ time.sleep(poll_interval)
514
+
515
+ typer.secho(
516
+ f"Timed out after {timeout:.0f}s waiting for analysis run {run_id}. "
517
+ "The analysis keeps going server-side — see the webapp's Issues page.",
518
+ fg=typer.colors.YELLOW,
519
+ err=True,
520
+ )
521
+ raise typer.Exit(EXIT_TRANSPORT)
522
+
523
+
524
+ def _print_analysis_outcome(run: dict[str, Any]) -> None:
525
+ status = run.get("status")
526
+ if status == "completed":
527
+ typer.echo("")
528
+ typer.echo(
529
+ f"Analysis complete: {run.get('chats_analyzed', 0)} conversation(s) analyzed, "
530
+ f"{run.get('occurrences_created', 0)} occurrence(s) found, "
531
+ f"{run.get('issues_touched', 0)} issue(s) created or updated."
532
+ )
533
+ typer.echo("Review them with `cassis issues list`.")
534
+ elif status == "cancelled":
535
+ typer.secho("Analysis cancelled before it finished.", fg=typer.colors.YELLOW, err=True)
536
+ else:
537
+ typer.secho(f"Analysis failed: {run.get('error') or 'unknown error'}", fg=typer.colors.RED, err=True)
@@ -440,6 +440,15 @@ checkout:
440
440
  Merging the pull request syncs and publishes the ontology; nothing reaches
441
441
  production answers until then.
442
442
 
443
+ When the change came from a Cassis issue, merging is still not the last step.
444
+ Confirm the published version contains it (`cassis status`, or
445
+ `get_project_status` over MCP), then propose resolving that issue —
446
+ `cassis issues resolve <id>`, or `update_issue_status`. Propose it to whoever
447
+ owns the project: resolving needs the editor or admin role, and it records an
448
+ outcome without changing any ontology. Never resolve before publication, and
449
+ never silently — an issue nobody resolves stays in the triage queue and reads
450
+ as a gap that was never fixed.
451
+
443
452
  ---
444
453
 
445
454
  *This guide is the single canonical home for Cassis ontology modeling doctrine.
@@ -231,7 +231,8 @@ def push(
231
231
  raise typer.Exit(EXIT_TRANSPORT) from exc
232
232
 
233
233
  run_id = run["run_id"]
234
- typer.echo(f"Detection run started: {run_id}")
234
+ # To stderr under --json so `cassis schema push --json | jq` gets only the record.
235
+ typer.echo(f"Detection run started: {run_id}", err=json_output)
235
236
 
236
237
  run = _wait_for_detection_run(
237
238
  api_url=api_url,
@@ -262,6 +263,7 @@ def push(
262
263
  typer.secho(
263
264
  f"✓ Detection completed: {total} change(s) to review." if total else "✓ Detection completed: no changes.",
264
265
  fg=typer.colors.GREEN,
266
+ err=json_output,
265
267
  )
266
268
  raise typer.Exit(EXIT_OK)
267
269
  if run_status == "failed":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "1.6.0"
3
+ version = "1.7.0"
4
4
  description = "Validate, test and evaluate your Cassis ontology from your terminal, then publish it"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
File without changes
File without changes