healthcloud-cli 3.7.8__tar.gz → 3.7.9__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.
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/PKG-INFO +52 -17
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/README.md +51 -16
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/commands.py +5 -4
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/operations.py +272 -34
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/context.py +76 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/errors.py +5 -4
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/pretty.py +26 -13
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/pyproject.toml +1 -1
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/.gitignore +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/hatch_build.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/admins/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/agents/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/auth.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/compatibility.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/domain_errors.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/output.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/policies/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/registry.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/results.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/session.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/sessions/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/skills/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/target.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/teams/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tools/projections.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/tuning.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/ui.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/windows_files.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/cli.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/context.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/contracts/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/contracts/mcp-tools.manifest.json +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/main.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/manifest.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/output.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/params.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/commands.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/group.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/tenants/operations.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/util.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/scripts/__init__.py +0 -0
- {healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/scripts/bundle_manifest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: healthcloud-cli
|
|
3
|
-
Version: 3.7.
|
|
3
|
+
Version: 3.7.9
|
|
4
4
|
Summary: Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest.
|
|
5
5
|
Project-URL: Issues, https://github.com/healthcloudservices/healthcloud-services/issues
|
|
6
6
|
Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-services
|
|
@@ -529,14 +529,15 @@ and a missing `--agent-id` fails with one envelope on stderr and exit 1:
|
|
|
529
529
|
```
|
|
530
530
|
|
|
531
531
|
Agent lifecycle changes are two first-class, send-once write commands. They
|
|
532
|
-
require a saved `hc login` session (
|
|
533
|
-
|
|
532
|
+
require a saved `hc login` session (the command refuses before any request when
|
|
533
|
+
none is saved; env-var credentials are not accepted here) and the `--confirm`
|
|
534
|
+
acknowledgement. `--expected-config-version` is optional:
|
|
534
535
|
|
|
535
536
|
```bash
|
|
536
537
|
uv run --frozen hc ams agents deactivate --environment dev --tenant-id TENANT_ID \
|
|
537
|
-
--agent-id AGENT_ID --expected-config-version N --confirm
|
|
538
|
+
--agent-id AGENT_ID [--expected-config-version N] --confirm
|
|
538
539
|
uv run --frozen hc ams agents activate --environment dev --tenant-id TENANT_ID \
|
|
539
|
-
--agent-id AGENT_ID --expected-config-version N --confirm
|
|
540
|
+
--agent-id AGENT_ID [--expected-config-version N] --confirm
|
|
540
541
|
```
|
|
541
542
|
|
|
542
543
|
Each command issues exactly one `GET /agents/{agent_id}`, local pre-checks,
|
|
@@ -566,9 +567,21 @@ explicitly that
|
|
|
566
567
|
fields — a disabled agent can still be selected by those paths — and that
|
|
567
568
|
in-flight runs, WSS sessions and schedules are untouched by this route.
|
|
568
569
|
|
|
570
|
+
`data.config_version_check` says which concurrent-edit check actually ran:
|
|
571
|
+
`matched` (the read `config_version` equals `--expected-config-version`),
|
|
572
|
+
`not_run` (the flag was omitted, so **no** version check ran and the reviewed
|
|
573
|
+
target plus `--confirm` are the approval), never a silent success. It is carried
|
|
574
|
+
on the confirmed, refused **and** unknown envelopes alike, so an outcome where
|
|
575
|
+
the write may already have committed still says which check ran. A deployment
|
|
576
|
+
whose agent payload omits or nulls `config_version` cannot honour the flag: the
|
|
577
|
+
command refuses that flag with a way forward (`Re-run without
|
|
578
|
+
--expected-config-version`) instead of refusing every lifecycle command forever
|
|
579
|
+
and instead of interpolating a Python `None` repr.
|
|
580
|
+
|
|
569
581
|
Default `pretty` output prints those facts as labelled lines — `transition`,
|
|
570
|
-
`requested`, `evidence` (`saved`, `side_effects`,
|
|
571
|
-
`mutation_replayed: false` — and closes with a
|
|
582
|
+
`requested`, `config_version_check`, `evidence` (`saved`, `side_effects`,
|
|
583
|
+
`runtime`), `eligibility`, `mutation_replayed: false` — and closes with a
|
|
584
|
+
`verification:` block, so a
|
|
572
585
|
deactivate reads `transition: deactivate`, then
|
|
573
586
|
`evidence: / saved: / status: confirmed`, then `verification: / saved_state:
|
|
574
587
|
confirmed`, `side_effects: unverified`, `runtime: unverified`; it is never one
|
|
@@ -592,24 +605,45 @@ tenant, an already in-effect state, an unsupported `archived`/`draft` target
|
|
|
592
605
|
(named as having no supported reversible transition here), or a
|
|
593
606
|
`config_version` that no longer matches `--expected-config-version` — all of
|
|
594
607
|
these are also enforced by the preview, which still sends nothing. A missing
|
|
595
|
-
`--confirm` exits 2.
|
|
608
|
+
`--confirm` exits 2. An `active` value the server emits as `1`/`0`/`"true"`/
|
|
609
|
+
`"false"` is interpreted as a flag (an already in-effect state is still
|
|
610
|
+
recognised and a correct write is still confirmed); any other value is
|
|
611
|
+
unconfirmed, never a false success. That version check is a local best-effort
|
|
596
612
|
guard only: the route has no `If-Match` and the server never compares the
|
|
597
613
|
version, so it cannot prove atomicity and a request could still race between
|
|
598
614
|
the read and the PUT.
|
|
599
615
|
|
|
600
|
-
A transport error
|
|
601
|
-
|
|
616
|
+
A transport error, a `5xx`/`408` from the PUT, or an interrupt (`Ctrl-C`) during
|
|
617
|
+
the PUT or the readback is reported as `outcome: "unknown"` with
|
|
618
|
+
`mutation_replayed: false`, `verification.saved_state: "unknown"` and a
|
|
602
619
|
`reconciliation: {"status": "not_read", …}` block; the CLI sends no retry and
|
|
603
620
|
does not re-read automatically — run `hc ams agents get` before doing anything
|
|
604
621
|
else. A failed readback after an accepted PUT is reported the same way (unknown,
|
|
605
622
|
not success). A `2xx` PUT answer whose body cannot be read as an agent object is
|
|
606
|
-
also `outcome: "unknown"` (
|
|
607
|
-
error and never a
|
|
623
|
+
also `outcome: "unknown"` (attempted once; the request may not have been sent
|
|
624
|
+
and its saved state is unverified), never an internal error and never a
|
|
625
|
+
success.
|
|
626
|
+
|
|
627
|
+
A non-`5xx` rejection of the PUT (`409`, `422`, `400`, `403`, …) is worded as a
|
|
628
|
+
**write** that the server refused, never as a read: `outcome: "failed"`,
|
|
629
|
+
`verification.saved_state: "failed"`, `mutation_replayed: false`, the pre-write
|
|
630
|
+
state under `data.evidence.saved.before`, and a `reconciliation` saying the CLI
|
|
631
|
+
did not re-read. A `2xx` body that is not an agent object at all (for example a
|
|
632
|
+
plain-text body) is a bounded `validation` refusal with the same
|
|
633
|
+
`verification: not_applicable` as any read, never an `AttributeError` and never a
|
|
634
|
+
traceback.
|
|
635
|
+
|
|
636
|
+
The saved-session requirement above is enforced at the command boundary: the
|
|
637
|
+
shared AMS authentication path still has the `HC_AMS_ADMIN_EMAIL`/
|
|
638
|
+
`HC_AMS_ADMIN_PASSWORD` tenant-admin fallback that every AMS command group uses,
|
|
639
|
+
so the guarantee is "these commands refuse without a saved session", not "no AMS
|
|
640
|
+
write anywhere can reach the server on env-var credentials".
|
|
608
641
|
|
|
609
642
|
Capability and organization assignments are one first-class, send-once write
|
|
610
|
-
command. It requires a saved `hc login` session (
|
|
611
|
-
|
|
612
|
-
`--confirm` acknowledgement of the
|
|
643
|
+
command. It requires a saved `hc login` session (the command refuses before any
|
|
644
|
+
request when none is saved; env-var credentials are not accepted here), an
|
|
645
|
+
explicit `--expected-config-version`, and the `--confirm` acknowledgement of the
|
|
646
|
+
target and the printed reviewed diff:
|
|
613
647
|
|
|
614
648
|
```bash
|
|
615
649
|
uv run --frozen hc ams agents assignments set --environment dev --tenant-id TENANT_ID \
|
|
@@ -706,8 +740,9 @@ carries a reference field is refused by field name by
|
|
|
706
740
|
capabilities.
|
|
707
741
|
|
|
708
742
|
Deleting an agent is one first-class, send-once destructive command. It requires
|
|
709
|
-
a saved `hc login` session (
|
|
710
|
-
|
|
743
|
+
a saved `hc login` session (the command refuses before any request when none is
|
|
744
|
+
saved; env-var credentials are not accepted here) and the `--confirm`
|
|
745
|
+
acknowledgement of the target and disclosure block it prints:
|
|
711
746
|
|
|
712
747
|
```bash
|
|
713
748
|
uv run --frozen hc ams agents delete --environment dev --tenant-id TENANT_ID \
|
|
@@ -511,14 +511,15 @@ and a missing `--agent-id` fails with one envelope on stderr and exit 1:
|
|
|
511
511
|
```
|
|
512
512
|
|
|
513
513
|
Agent lifecycle changes are two first-class, send-once write commands. They
|
|
514
|
-
require a saved `hc login` session (
|
|
515
|
-
|
|
514
|
+
require a saved `hc login` session (the command refuses before any request when
|
|
515
|
+
none is saved; env-var credentials are not accepted here) and the `--confirm`
|
|
516
|
+
acknowledgement. `--expected-config-version` is optional:
|
|
516
517
|
|
|
517
518
|
```bash
|
|
518
519
|
uv run --frozen hc ams agents deactivate --environment dev --tenant-id TENANT_ID \
|
|
519
|
-
--agent-id AGENT_ID --expected-config-version N --confirm
|
|
520
|
+
--agent-id AGENT_ID [--expected-config-version N] --confirm
|
|
520
521
|
uv run --frozen hc ams agents activate --environment dev --tenant-id TENANT_ID \
|
|
521
|
-
--agent-id AGENT_ID --expected-config-version N --confirm
|
|
522
|
+
--agent-id AGENT_ID [--expected-config-version N] --confirm
|
|
522
523
|
```
|
|
523
524
|
|
|
524
525
|
Each command issues exactly one `GET /agents/{agent_id}`, local pre-checks,
|
|
@@ -548,9 +549,21 @@ explicitly that
|
|
|
548
549
|
fields — a disabled agent can still be selected by those paths — and that
|
|
549
550
|
in-flight runs, WSS sessions and schedules are untouched by this route.
|
|
550
551
|
|
|
552
|
+
`data.config_version_check` says which concurrent-edit check actually ran:
|
|
553
|
+
`matched` (the read `config_version` equals `--expected-config-version`),
|
|
554
|
+
`not_run` (the flag was omitted, so **no** version check ran and the reviewed
|
|
555
|
+
target plus `--confirm` are the approval), never a silent success. It is carried
|
|
556
|
+
on the confirmed, refused **and** unknown envelopes alike, so an outcome where
|
|
557
|
+
the write may already have committed still says which check ran. A deployment
|
|
558
|
+
whose agent payload omits or nulls `config_version` cannot honour the flag: the
|
|
559
|
+
command refuses that flag with a way forward (`Re-run without
|
|
560
|
+
--expected-config-version`) instead of refusing every lifecycle command forever
|
|
561
|
+
and instead of interpolating a Python `None` repr.
|
|
562
|
+
|
|
551
563
|
Default `pretty` output prints those facts as labelled lines — `transition`,
|
|
552
|
-
`requested`, `evidence` (`saved`, `side_effects`,
|
|
553
|
-
`mutation_replayed: false` — and closes with a
|
|
564
|
+
`requested`, `config_version_check`, `evidence` (`saved`, `side_effects`,
|
|
565
|
+
`runtime`), `eligibility`, `mutation_replayed: false` — and closes with a
|
|
566
|
+
`verification:` block, so a
|
|
554
567
|
deactivate reads `transition: deactivate`, then
|
|
555
568
|
`evidence: / saved: / status: confirmed`, then `verification: / saved_state:
|
|
556
569
|
confirmed`, `side_effects: unverified`, `runtime: unverified`; it is never one
|
|
@@ -574,24 +587,45 @@ tenant, an already in-effect state, an unsupported `archived`/`draft` target
|
|
|
574
587
|
(named as having no supported reversible transition here), or a
|
|
575
588
|
`config_version` that no longer matches `--expected-config-version` — all of
|
|
576
589
|
these are also enforced by the preview, which still sends nothing. A missing
|
|
577
|
-
`--confirm` exits 2.
|
|
590
|
+
`--confirm` exits 2. An `active` value the server emits as `1`/`0`/`"true"`/
|
|
591
|
+
`"false"` is interpreted as a flag (an already in-effect state is still
|
|
592
|
+
recognised and a correct write is still confirmed); any other value is
|
|
593
|
+
unconfirmed, never a false success. That version check is a local best-effort
|
|
578
594
|
guard only: the route has no `If-Match` and the server never compares the
|
|
579
595
|
version, so it cannot prove atomicity and a request could still race between
|
|
580
596
|
the read and the PUT.
|
|
581
597
|
|
|
582
|
-
A transport error
|
|
583
|
-
|
|
598
|
+
A transport error, a `5xx`/`408` from the PUT, or an interrupt (`Ctrl-C`) during
|
|
599
|
+
the PUT or the readback is reported as `outcome: "unknown"` with
|
|
600
|
+
`mutation_replayed: false`, `verification.saved_state: "unknown"` and a
|
|
584
601
|
`reconciliation: {"status": "not_read", …}` block; the CLI sends no retry and
|
|
585
602
|
does not re-read automatically — run `hc ams agents get` before doing anything
|
|
586
603
|
else. A failed readback after an accepted PUT is reported the same way (unknown,
|
|
587
604
|
not success). A `2xx` PUT answer whose body cannot be read as an agent object is
|
|
588
|
-
also `outcome: "unknown"` (
|
|
589
|
-
error and never a
|
|
605
|
+
also `outcome: "unknown"` (attempted once; the request may not have been sent
|
|
606
|
+
and its saved state is unverified), never an internal error and never a
|
|
607
|
+
success.
|
|
608
|
+
|
|
609
|
+
A non-`5xx` rejection of the PUT (`409`, `422`, `400`, `403`, …) is worded as a
|
|
610
|
+
**write** that the server refused, never as a read: `outcome: "failed"`,
|
|
611
|
+
`verification.saved_state: "failed"`, `mutation_replayed: false`, the pre-write
|
|
612
|
+
state under `data.evidence.saved.before`, and a `reconciliation` saying the CLI
|
|
613
|
+
did not re-read. A `2xx` body that is not an agent object at all (for example a
|
|
614
|
+
plain-text body) is a bounded `validation` refusal with the same
|
|
615
|
+
`verification: not_applicable` as any read, never an `AttributeError` and never a
|
|
616
|
+
traceback.
|
|
617
|
+
|
|
618
|
+
The saved-session requirement above is enforced at the command boundary: the
|
|
619
|
+
shared AMS authentication path still has the `HC_AMS_ADMIN_EMAIL`/
|
|
620
|
+
`HC_AMS_ADMIN_PASSWORD` tenant-admin fallback that every AMS command group uses,
|
|
621
|
+
so the guarantee is "these commands refuse without a saved session", not "no AMS
|
|
622
|
+
write anywhere can reach the server on env-var credentials".
|
|
590
623
|
|
|
591
624
|
Capability and organization assignments are one first-class, send-once write
|
|
592
|
-
command. It requires a saved `hc login` session (
|
|
593
|
-
|
|
594
|
-
`--confirm` acknowledgement of the
|
|
625
|
+
command. It requires a saved `hc login` session (the command refuses before any
|
|
626
|
+
request when none is saved; env-var credentials are not accepted here), an
|
|
627
|
+
explicit `--expected-config-version`, and the `--confirm` acknowledgement of the
|
|
628
|
+
target and the printed reviewed diff:
|
|
595
629
|
|
|
596
630
|
```bash
|
|
597
631
|
uv run --frozen hc ams agents assignments set --environment dev --tenant-id TENANT_ID \
|
|
@@ -688,8 +722,9 @@ carries a reference field is refused by field name by
|
|
|
688
722
|
capabilities.
|
|
689
723
|
|
|
690
724
|
Deleting an agent is one first-class, send-once destructive command. It requires
|
|
691
|
-
a saved `hc login` session (
|
|
692
|
-
|
|
725
|
+
a saved `hc login` session (the command refuses before any request when none is
|
|
726
|
+
saved; env-var credentials are not accepted here) and the `--confirm`
|
|
727
|
+
acknowledgement of the target and disclosure block it prints:
|
|
693
728
|
|
|
694
729
|
```bash
|
|
695
730
|
uv run --frozen hc ams agents delete --environment dev --tenant-id TENANT_ID \
|
|
@@ -93,10 +93,11 @@ def create_lifecycle_command(dependencies: AmsDependencies, transition: str) ->
|
|
|
93
93
|
@_agent
|
|
94
94
|
@click.option(
|
|
95
95
|
"--expected-config-version",
|
|
96
|
-
required=True,
|
|
97
96
|
type=int,
|
|
98
|
-
|
|
99
|
-
"
|
|
97
|
+
default=None,
|
|
98
|
+
help="Optional: the config_version read before the write. A local best-effort guard only, because "
|
|
99
|
+
"the route has no If-Match. Omitting it runs no version check and says so in the envelope; a "
|
|
100
|
+
"deployment that reports no config_version refuses the flag rather than the whole command.",
|
|
100
101
|
)
|
|
101
102
|
@click.option(
|
|
102
103
|
"--confirm",
|
|
@@ -111,7 +112,7 @@ def create_lifecycle_command(dependencies: AmsDependencies, transition: str) ->
|
|
|
111
112
|
environment: str | None,
|
|
112
113
|
tenant_id: str | None,
|
|
113
114
|
agent_id: str,
|
|
114
|
-
expected_config_version: int,
|
|
115
|
+
expected_config_version: int | None,
|
|
115
116
|
confirm: bool,
|
|
116
117
|
) -> None:
|
|
117
118
|
target_data = {
|
|
@@ -50,9 +50,32 @@ def list_agents(dependencies: AmsDependencies, target: TenantTarget) -> dict:
|
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _agent_object(value) -> bool:
|
|
54
|
+
"""True only for a body that really is an agent object.
|
|
55
|
+
|
|
56
|
+
``TypedClient.model`` returns a non-dict 200 body unchanged (``http.py``
|
|
57
|
+
hands back ``resp.text`` when JSON parsing fails), so an id/tenant check
|
|
58
|
+
written as ``value.id`` would crash with ``AttributeError`` and reach the
|
|
59
|
+
operator as a generic internal failure - after a committed write, in the
|
|
60
|
+
readback case. Every agent read and readback goes through this guard first.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
value is not None
|
|
65
|
+
and not isinstance(value, (str, bytes, bytearray, int, float, list, tuple, dict))
|
|
66
|
+
and getattr(value, "id", None) is not None
|
|
67
|
+
and getattr(value, "tenant_id", None) is not None
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
53
71
|
def _verified_agent(sdk, target: TenantTarget, agent_id: str):
|
|
54
72
|
agent = sdk.ams_admin.get_agent(agent_id)
|
|
55
|
-
if
|
|
73
|
+
if not _agent_object(agent):
|
|
74
|
+
raise AgentReadRefused(
|
|
75
|
+
"validation",
|
|
76
|
+
f"The server did not return an agent object for '{agent_id}'; stopped before reading or writing anything.",
|
|
77
|
+
)
|
|
78
|
+
if agent.id != agent_id or agent.tenant_id != target.tenant_id:
|
|
56
79
|
raise AgentReadRefused("validation", "Agent/tenant identity mismatch; stopped.")
|
|
57
80
|
return agent
|
|
58
81
|
|
|
@@ -110,10 +133,22 @@ UNKNOWN_LIFECYCLE_OUTCOME = (
|
|
|
110
133
|
"`hc ams agents get` before doing anything else."
|
|
111
134
|
)
|
|
112
135
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
# A TypeError (a drifted/local SDK fault around the call) and a ValueError (a 2xx
|
|
137
|
+
# body that cannot be built into an agent) are indistinguishable at the call
|
|
138
|
+
# site, so the message covers both honestly instead of claiming a send: the
|
|
139
|
+
# request may never have left the process, and if it did, its outcome is
|
|
140
|
+
# unknown.
|
|
141
|
+
MAYBE_UNSENT_LIFECYCLE_OUTCOME = (
|
|
142
|
+
"The agent lifecycle write was attempted once and the installed SDK raised instead of returning a "
|
|
143
|
+
"readable agent response, so its outcome is unknown: the request may not have been sent, and if it "
|
|
144
|
+
"was, it may have been applied. The mutation was not replayed. Re-read the agent with "
|
|
145
|
+
"`hc ams agents get` before doing anything else."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
INTERRUPTED_LIFECYCLE_OUTCOME = (
|
|
149
|
+
"The agent lifecycle write was interrupted after the request was attempted, so its outcome is unknown: "
|
|
150
|
+
"it may already have been applied. The mutation was not replayed. Re-read the agent with "
|
|
151
|
+
"`hc ams agents get` before doing anything else."
|
|
117
152
|
)
|
|
118
153
|
|
|
119
154
|
_RUNTIME_UNVERIFIED = (
|
|
@@ -152,7 +187,89 @@ def _state(agent) -> dict:
|
|
|
152
187
|
}
|
|
153
188
|
|
|
154
189
|
|
|
155
|
-
def
|
|
190
|
+
def _active_flag(value) -> bool | None:
|
|
191
|
+
"""Interpret a server ``active`` value, or ``None`` when it is not a flag.
|
|
192
|
+
|
|
193
|
+
``AgentConfig`` is a plain dataclass fed by ``json.loads``: a server that
|
|
194
|
+
emits ``1``/``0``/``"true"`` would make an identity check (``1 is True``)
|
|
195
|
+
false, so a correctly applied write would be reported "not confirmed" and the
|
|
196
|
+
already-in-effect precheck would not fire. Never a false success: only an
|
|
197
|
+
unambiguous flag maps, everything else stays unknown.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
if isinstance(value, bool):
|
|
201
|
+
return value
|
|
202
|
+
if isinstance(value, int) and value in (0, 1):
|
|
203
|
+
return bool(value)
|
|
204
|
+
if isinstance(value, str):
|
|
205
|
+
lowered = value.strip().lower()
|
|
206
|
+
if lowered in ("true", "1", "yes"):
|
|
207
|
+
return True
|
|
208
|
+
if lowered in ("false", "0", "no"):
|
|
209
|
+
return False
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _usable_config_version(value) -> bool:
|
|
214
|
+
"""A ``config_version`` is only usable as a number; a bool, string or None is not.
|
|
215
|
+
|
|
216
|
+
``AgentConfig`` is a plain dataclass fed by ``json.loads``, so a deployment can
|
|
217
|
+
legitimately emit ``"3"``. Nothing downstream may assume ``+ 1`` works on it.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _config_version_check(agent, agent_id: str, expected_config_version: int | None) -> dict:
|
|
224
|
+
"""Report which concurrency check actually ran, refusing a check that cannot.
|
|
225
|
+
|
|
226
|
+
``--expected-config-version`` is a local best-effort guard the route cannot
|
|
227
|
+
honour (no If-Match). A deployment whose agent payload omits or nulls
|
|
228
|
+
``config_version`` has no version to compare against, so asking for the check
|
|
229
|
+
is refused with a way forward - but omitting the flag is never a silent
|
|
230
|
+
success: the envelope says plainly that no version check ran.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
observed = getattr(agent, "config_version", None)
|
|
234
|
+
usable = _usable_config_version(observed)
|
|
235
|
+
if expected_config_version is None:
|
|
236
|
+
return {
|
|
237
|
+
"status": "not_run",
|
|
238
|
+
"expected": None,
|
|
239
|
+
"observed": observed if usable else None,
|
|
240
|
+
"detail": (
|
|
241
|
+
"--expected-config-version was not passed, so no local concurrent-edit check ran. The "
|
|
242
|
+
"route has no If-Match and the server never compares a version; the reviewed target and "
|
|
243
|
+
"--confirm are the approval."
|
|
244
|
+
),
|
|
245
|
+
}
|
|
246
|
+
if not usable:
|
|
247
|
+
raise AgentWriteRefused(
|
|
248
|
+
"validation",
|
|
249
|
+
f"Agent '{agent_id}' reports no usable config_version, so --expected-config-version cannot be "
|
|
250
|
+
"checked against it; nothing was sent. Re-run without --expected-config-version to send this "
|
|
251
|
+
"change with no local version check, or repair the agent's config_version first.",
|
|
252
|
+
)
|
|
253
|
+
if observed != expected_config_version:
|
|
254
|
+
raise AgentWriteRefused(
|
|
255
|
+
"validation",
|
|
256
|
+
f"--expected-config-version {expected_config_version} does not match the agent's config_version "
|
|
257
|
+
f"{observed}; a concurrent edit was refused locally and nothing was sent. This is a "
|
|
258
|
+
"best-effort local check because the route has no If-Match: the server never compares the "
|
|
259
|
+
"version, so a request could still race between a read and the write.",
|
|
260
|
+
)
|
|
261
|
+
return {
|
|
262
|
+
"status": "matched",
|
|
263
|
+
"expected": expected_config_version,
|
|
264
|
+
"observed": observed,
|
|
265
|
+
"detail": (
|
|
266
|
+
"The read config_version equals --expected-config-version. This is a best-effort local check "
|
|
267
|
+
"only: the route has no If-Match, so the server never compared it."
|
|
268
|
+
),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _lifecycle_precheck(agent, agent_id: str, status: str, active: bool, expected_config_version: int | None) -> None:
|
|
156
273
|
"""Refuse locally, before the one write, when the transition is not valid."""
|
|
157
274
|
|
|
158
275
|
if agent.status in ("archived", "draft"):
|
|
@@ -167,15 +284,8 @@ def _lifecycle_precheck(agent, agent_id: str, status: str, active: bool, expecte
|
|
|
167
284
|
f"Agent '{agent_id}' reports an unsupported lifecycle status '{agent.status}'; only 'active' and "
|
|
168
285
|
"'disabled' are written. Nothing was sent.",
|
|
169
286
|
)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
"validation",
|
|
173
|
-
f"--expected-config-version {expected_config_version} does not match the agent's config_version "
|
|
174
|
-
f"{agent.config_version}; a concurrent edit was refused locally and nothing was sent. This is a "
|
|
175
|
-
"best-effort local check because the route has no If-Match: the server never compares the "
|
|
176
|
-
"version, so a request could still race between a read and the write.",
|
|
177
|
-
)
|
|
178
|
-
if agent.status == status and agent.active is active:
|
|
287
|
+
_config_version_check(agent, agent_id, expected_config_version)
|
|
288
|
+
if agent.status == status and _active_flag(agent.active) is active:
|
|
179
289
|
raise AgentWriteRefused(
|
|
180
290
|
"validation",
|
|
181
291
|
f"Agent '{agent_id}' is already status '{status}' with active {str(active).lower()}; the change "
|
|
@@ -196,15 +306,23 @@ def _usable_readback(agent, agent_id: str, target: TenantTarget) -> bool:
|
|
|
196
306
|
and getattr(agent, "id", None) == agent_id
|
|
197
307
|
and getattr(agent, "tenant_id", None) == target.tenant_id
|
|
198
308
|
and getattr(agent, "status", None) is not None
|
|
199
|
-
and getattr(agent, "active", None) is not None
|
|
309
|
+
and _active_flag(getattr(agent, "active", None)) is not None
|
|
200
310
|
)
|
|
201
311
|
|
|
202
312
|
|
|
203
313
|
def _lifecycle_evidence(before, after, status: str, active: bool, agent_id: str, target: TenantTarget) -> dict:
|
|
204
314
|
identity = after is not None and after.id == agent_id and after.tenant_id == target.tenant_id
|
|
205
|
-
matched = identity and after.status == status and after.active is active
|
|
315
|
+
matched = identity and after.status == status and _active_flag(after.active) is active
|
|
316
|
+
# Making --expected-config-version optional removed the pre-write refusal that
|
|
317
|
+
# used to reject a non-int config_version, so this runs after the write: it must
|
|
318
|
+
# degrade to "not observed", never raise on a value it cannot add to.
|
|
319
|
+
before_version = getattr(before, "config_version", None)
|
|
320
|
+
after_version = None if after is None else getattr(after, "config_version", None)
|
|
206
321
|
advanced = (
|
|
207
|
-
after is not None
|
|
322
|
+
after is not None
|
|
323
|
+
and _usable_config_version(before_version)
|
|
324
|
+
and _usable_config_version(after_version)
|
|
325
|
+
and after_version == before_version + 1
|
|
208
326
|
)
|
|
209
327
|
if matched:
|
|
210
328
|
detail = "The readback shows exactly the status/active pair that was sent for this agent in this tenant."
|
|
@@ -259,7 +377,14 @@ def _eligibility(after) -> dict:
|
|
|
259
377
|
|
|
260
378
|
|
|
261
379
|
def _lifecycle_envelope(
|
|
262
|
-
target: TenantTarget,
|
|
380
|
+
target: TenantTarget,
|
|
381
|
+
agent_id: str,
|
|
382
|
+
transition: str,
|
|
383
|
+
status: str,
|
|
384
|
+
active: bool,
|
|
385
|
+
before,
|
|
386
|
+
after,
|
|
387
|
+
version_check: dict,
|
|
263
388
|
) -> dict:
|
|
264
389
|
envelope = succeeded(
|
|
265
390
|
f"agents.{transition}",
|
|
@@ -267,6 +392,7 @@ def _lifecycle_envelope(
|
|
|
267
392
|
{
|
|
268
393
|
"transition": transition,
|
|
269
394
|
"requested": {"status": status, "active": active},
|
|
395
|
+
"config_version_check": version_check,
|
|
270
396
|
"evidence": _lifecycle_evidence(before, after, status, active, agent_id, target),
|
|
271
397
|
"eligibility": _eligibility(after),
|
|
272
398
|
"mutation_replayed": False,
|
|
@@ -281,17 +407,89 @@ def _lifecycle_envelope(
|
|
|
281
407
|
return envelope
|
|
282
408
|
|
|
283
409
|
|
|
410
|
+
def _rejected_lifecycle(
|
|
411
|
+
operation: str,
|
|
412
|
+
target: TenantTarget,
|
|
413
|
+
agent_id: str,
|
|
414
|
+
status: str,
|
|
415
|
+
active: bool,
|
|
416
|
+
before,
|
|
417
|
+
version_check: dict,
|
|
418
|
+
http_status: int,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Report a write the server refused: sent once, definitively not saved.
|
|
421
|
+
|
|
422
|
+
The request left the process, so the outcome must not be described as a read
|
|
423
|
+
("read conflicted") and the verification block must not claim
|
|
424
|
+
``not_applicable``: the server answered, so ``saved_state`` is ``failed``.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
from ..errors import http_category
|
|
428
|
+
|
|
429
|
+
category = http_category(operation, http_status)
|
|
430
|
+
message = (
|
|
431
|
+
f"Agent '{agent_id}' lifecycle write was sent once and the server refused it with HTTP {http_status}; "
|
|
432
|
+
"nothing was written, nothing was replayed and no readback was taken. Review the agent with "
|
|
433
|
+
"`hc ams agents get` before retrying."
|
|
434
|
+
)
|
|
435
|
+
envelope = succeeded(
|
|
436
|
+
operation,
|
|
437
|
+
_lifecycle_target(target, agent_id),
|
|
438
|
+
{
|
|
439
|
+
"transition": operation.split(".", 1)[-1],
|
|
440
|
+
"requested": {"status": status, "active": active},
|
|
441
|
+
"config_version_check": version_check,
|
|
442
|
+
"mutation_replayed": False,
|
|
443
|
+
"reconciliation": {
|
|
444
|
+
"status": "not_read",
|
|
445
|
+
"detail": (
|
|
446
|
+
"The server refused the write, so this command did not re-read the agent. Re-read with "
|
|
447
|
+
"`hc ams agents get` before doing anything else."
|
|
448
|
+
),
|
|
449
|
+
},
|
|
450
|
+
"evidence": {
|
|
451
|
+
"saved": {
|
|
452
|
+
"status": "failed",
|
|
453
|
+
"agent_id": agent_id,
|
|
454
|
+
"detail": (
|
|
455
|
+
"The server refused the lifecycle write, so the requested status/active pair is "
|
|
456
|
+
"not saved. The state below was read before the write was sent."
|
|
457
|
+
),
|
|
458
|
+
"before": _state(before),
|
|
459
|
+
},
|
|
460
|
+
"side_effects": {"status": "unverified", "detail": _SIDE_EFFECTS_UNVERIFIED},
|
|
461
|
+
"runtime": {"status": "unverified", "detail": _RUNTIME_UNVERIFIED},
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
read_only=False,
|
|
465
|
+
)
|
|
466
|
+
envelope["outcome"] = "failed"
|
|
467
|
+
envelope["error"] = {"category": category, "message": message}
|
|
468
|
+
envelope["verification"] = {
|
|
469
|
+
"saved_state": "failed",
|
|
470
|
+
"side_effects": "unverified",
|
|
471
|
+
"runtime": "unverified",
|
|
472
|
+
}
|
|
473
|
+
raise DomainOutcomeFailed(category, message, envelope)
|
|
474
|
+
|
|
475
|
+
|
|
284
476
|
def _unknown_lifecycle(
|
|
285
477
|
operation: str,
|
|
286
478
|
target: TenantTarget,
|
|
287
479
|
agent_id: str,
|
|
288
480
|
status: str,
|
|
289
481
|
active: bool,
|
|
482
|
+
version_check: dict,
|
|
290
483
|
*,
|
|
291
484
|
reconciliation_detail: str,
|
|
292
485
|
message: str = UNKNOWN_LIFECYCLE_OUTCOME,
|
|
293
486
|
) -> None:
|
|
294
|
-
"""Report an uncertain send once, without replaying or re-reading it.
|
|
487
|
+
"""Report an uncertain send once, without replaying or re-reading it.
|
|
488
|
+
|
|
489
|
+
``version_check`` is carried on this envelope too: the write may have
|
|
490
|
+
committed, so which concurrent-edit check actually ran is exactly what the
|
|
491
|
+
operator needs while deciding whether a second edit could have been in play.
|
|
492
|
+
"""
|
|
295
493
|
|
|
296
494
|
envelope = unknown(
|
|
297
495
|
operation,
|
|
@@ -302,6 +500,7 @@ def _unknown_lifecycle(
|
|
|
302
500
|
"mutation_replayed": False,
|
|
303
501
|
"requested": {"status": status, "active": active},
|
|
304
502
|
"reconciliation": {"status": "not_read", "detail": reconciliation_detail},
|
|
503
|
+
"config_version_check": version_check,
|
|
305
504
|
},
|
|
306
505
|
read_only=False,
|
|
307
506
|
)
|
|
@@ -319,27 +518,47 @@ def set_lifecycle(
|
|
|
319
518
|
agent_id: str,
|
|
320
519
|
*,
|
|
321
520
|
transition: Literal["activate", "deactivate"],
|
|
322
|
-
expected_config_version: int,
|
|
521
|
+
expected_config_version: int | None,
|
|
323
522
|
) -> dict:
|
|
324
523
|
"""Change one agent's lifecycle availability once, then read it back.
|
|
325
524
|
|
|
326
525
|
Sequence: exactly one ``GET /agents/{agent_id}``, local pre-checks, exactly
|
|
327
526
|
one ``PUT /agents/{agent_id}`` whose body is only ``status``+``active``, then
|
|
328
527
|
exactly one ``GET`` readback. There is never a retry, a replay, a second
|
|
329
|
-
write or a generic update fallback. ``--expected-config-version`` is
|
|
330
|
-
best-effort guard
|
|
528
|
+
write or a generic update fallback. ``--expected-config-version`` is an
|
|
529
|
+
optional local best-effort guard: the route has no version/If-Match check,
|
|
530
|
+
and omitting the flag is reported as ``config_version_check: not_run`` rather
|
|
531
|
+
than pretending a check happened.
|
|
331
532
|
"""
|
|
332
533
|
|
|
333
534
|
from healthcloud.errors import HealthCloudHTTPError, HealthCloudNetworkError
|
|
334
535
|
|
|
536
|
+
from ..results import is_unknown_write_status
|
|
537
|
+
|
|
335
538
|
agent_id = _agent_id(agent_id)
|
|
336
539
|
status, active = _lifecycle_write_pair(transition)
|
|
337
540
|
operation = f"agents.{transition}"
|
|
338
541
|
with dependencies.authenticated_sdk_factory(target) as sdk:
|
|
339
542
|
before = _verified_agent(sdk, target, agent_id)
|
|
340
543
|
_lifecycle_precheck(before, agent_id, status, active, expected_config_version)
|
|
544
|
+
version_check = _config_version_check(before, agent_id, expected_config_version)
|
|
341
545
|
try:
|
|
342
546
|
sdk.ams_admin.set_agent_lifecycle(agent_id, status=status, active=active)
|
|
547
|
+
except KeyboardInterrupt:
|
|
548
|
+
# BaseException: every ordinary handler above is bypassed, and the
|
|
549
|
+
# write may already have committed. Report the uncertain outcome
|
|
550
|
+
# instead of a plain transport failure that claims nothing was sent.
|
|
551
|
+
_unknown_lifecycle(
|
|
552
|
+
operation,
|
|
553
|
+
target,
|
|
554
|
+
agent_id,
|
|
555
|
+
status,
|
|
556
|
+
active,
|
|
557
|
+
version_check,
|
|
558
|
+
message=INTERRUPTED_LIFECYCLE_OUTCOME,
|
|
559
|
+
reconciliation_detail="The command was interrupted after the write was attempted and did not "
|
|
560
|
+
"re-read the agent; run `hc ams agents get` before doing anything else.",
|
|
561
|
+
)
|
|
343
562
|
except HealthCloudNetworkError:
|
|
344
563
|
_unknown_lifecycle(
|
|
345
564
|
operation,
|
|
@@ -347,38 +566,56 @@ def set_lifecycle(
|
|
|
347
566
|
agent_id,
|
|
348
567
|
status,
|
|
349
568
|
active,
|
|
569
|
+
version_check,
|
|
350
570
|
reconciliation_detail="This command did not re-read the agent after the uncertain write; "
|
|
351
571
|
"run `hc ams agents get` before doing anything else.",
|
|
352
572
|
)
|
|
353
573
|
except HealthCloudHTTPError as error:
|
|
354
|
-
|
|
574
|
+
http_status = getattr(error, "status_code", None)
|
|
575
|
+
# No usable status is itself an uncertain answer for a sent write.
|
|
576
|
+
if not isinstance(http_status, int) or is_unknown_write_status(http_status):
|
|
355
577
|
_unknown_lifecycle(
|
|
356
578
|
operation,
|
|
357
579
|
target,
|
|
358
580
|
agent_id,
|
|
359
581
|
status,
|
|
360
582
|
active,
|
|
583
|
+
version_check,
|
|
361
584
|
reconciliation_detail="This command did not re-read the agent after the uncertain write; "
|
|
362
585
|
"run `hc ams agents get` before doing anything else.",
|
|
363
586
|
)
|
|
364
|
-
|
|
587
|
+
# A non-5xx answer is a definite refusal of a request that was sent:
|
|
588
|
+
# never described as a read, and never ``saved_state: not_applicable``.
|
|
589
|
+
_rejected_lifecycle(operation, target, agent_id, status, active, before, version_check, http_status)
|
|
365
590
|
except TypeError, ValueError:
|
|
366
|
-
#
|
|
367
|
-
# agent
|
|
368
|
-
#
|
|
369
|
-
# and never a success.
|
|
591
|
+
# Either the PUT was answered 2xx and the body could not be built
|
|
592
|
+
# into an agent, or a drifted SDK raised around the call. Both were
|
|
593
|
+
# attempted once, neither is a crash and neither is a success.
|
|
370
594
|
_unknown_lifecycle(
|
|
371
595
|
operation,
|
|
372
596
|
target,
|
|
373
597
|
agent_id,
|
|
374
598
|
status,
|
|
375
599
|
active,
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
"
|
|
600
|
+
version_check,
|
|
601
|
+
message=MAYBE_UNSENT_LIFECYCLE_OUTCOME,
|
|
602
|
+
reconciliation_detail="The SDK did not report a readable response, so the request may not "
|
|
603
|
+
"have been sent. Re-read with `hc ams agents get` before doing anything else.",
|
|
379
604
|
)
|
|
380
605
|
try:
|
|
381
606
|
after = sdk.ams_admin.get_agent(agent_id)
|
|
607
|
+
except KeyboardInterrupt:
|
|
608
|
+
_unknown_lifecycle(
|
|
609
|
+
operation,
|
|
610
|
+
target,
|
|
611
|
+
agent_id,
|
|
612
|
+
status,
|
|
613
|
+
active,
|
|
614
|
+
version_check,
|
|
615
|
+
message=INTERRUPTED_LIFECYCLE_OUTCOME,
|
|
616
|
+
reconciliation_detail="The write was accepted, but the command was interrupted during the "
|
|
617
|
+
"readback; run `hc ams agents get` before doing anything else.",
|
|
618
|
+
)
|
|
382
619
|
except TypeError, ValueError:
|
|
383
620
|
# An accepted write with an unreadable readback is unconfirmed
|
|
384
621
|
# evidence, not an internal error and not a replay: fall through to
|
|
@@ -391,12 +628,13 @@ def set_lifecycle(
|
|
|
391
628
|
agent_id,
|
|
392
629
|
status,
|
|
393
630
|
active,
|
|
631
|
+
version_check,
|
|
394
632
|
reconciliation_detail="The write was accepted, but the readback could not be read. The saved "
|
|
395
633
|
"state is unverified; run `hc ams agents get` before doing anything else.",
|
|
396
634
|
)
|
|
397
635
|
if not _usable_readback(after, agent_id, target):
|
|
398
636
|
after = None
|
|
399
|
-
envelope = _lifecycle_envelope(target, agent_id, transition, status, active, before, after)
|
|
637
|
+
envelope = _lifecycle_envelope(target, agent_id, transition, status, active, before, after, version_check)
|
|
400
638
|
if envelope["data"]["evidence"]["saved"]["status"] == "confirmed":
|
|
401
639
|
return envelope
|
|
402
640
|
if after is None:
|
|
@@ -1118,7 +1356,7 @@ def preview_lifecycle(
|
|
|
1118
1356
|
agent_id: str,
|
|
1119
1357
|
*,
|
|
1120
1358
|
transition: Literal["activate", "deactivate"],
|
|
1121
|
-
expected_config_version: int,
|
|
1359
|
+
expected_config_version: int | None,
|
|
1122
1360
|
) -> None:
|
|
1123
1361
|
"""Print the exact lifecycle change without sending it.
|
|
1124
1362
|
|
|
@@ -103,6 +103,75 @@ def preflight_discovery(invocation: AmsContext) -> None:
|
|
|
103
103
|
raise click.ClickException("Discover requires explicit environment/tenant flags, without a target file.")
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
# The exact call this CLI makes on each ``ams_admin`` agent method: positional
|
|
107
|
+
# argument count plus the keyword names it passes. A callable check alone is not
|
|
108
|
+
# enough: an installed SDK whose signature drifted raises ``TypeError`` before any
|
|
109
|
+
# bytes leave the process, which a sent-once write must never report as "may have
|
|
110
|
+
# committed". Refusing the drifted signature here keeps that impossible.
|
|
111
|
+
_AGENT_METHOD_CALLS = {
|
|
112
|
+
"get_agent": (1, ()),
|
|
113
|
+
"delete_agent": (1, ()),
|
|
114
|
+
"set_agent_lifecycle": (1, ("status", "active")),
|
|
115
|
+
"set_agent_assignments": (
|
|
116
|
+
1,
|
|
117
|
+
(
|
|
118
|
+
"department_id",
|
|
119
|
+
"team_id",
|
|
120
|
+
"skill_ids",
|
|
121
|
+
"tool_ids",
|
|
122
|
+
"policy_ids",
|
|
123
|
+
"guardrail_ids",
|
|
124
|
+
"data_source_ids",
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _signature_binds(method, positional: int, keywords: tuple[str, ...]) -> bool:
|
|
131
|
+
"""True when ``method`` accepts this CLI's call; un-introspectable is allowed."""
|
|
132
|
+
|
|
133
|
+
import inspect
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
params = list(inspect.signature(method).parameters.values())
|
|
137
|
+
except TypeError, ValueError:
|
|
138
|
+
# A C callable or a stub tells us nothing; the call site keeps its own guard.
|
|
139
|
+
return True
|
|
140
|
+
var_keyword = any(param.kind is inspect.Parameter.VAR_KEYWORD for param in params)
|
|
141
|
+
var_positional = any(param.kind is inspect.Parameter.VAR_POSITIONAL for param in params)
|
|
142
|
+
named = {param.name: param for param in params}
|
|
143
|
+
for keyword in keywords:
|
|
144
|
+
param = named.get(keyword)
|
|
145
|
+
if param is not None and param.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
146
|
+
return False
|
|
147
|
+
if param is None and not var_keyword:
|
|
148
|
+
return False
|
|
149
|
+
positional_params = [
|
|
150
|
+
param
|
|
151
|
+
for param in params
|
|
152
|
+
if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
153
|
+
]
|
|
154
|
+
if not var_positional and positional > len(positional_params):
|
|
155
|
+
return False
|
|
156
|
+
# A required POSITIONAL_OR_KEYWORD parameter the CLI passes by name is covered by
|
|
157
|
+
# ``keywords``, so it must not also be counted against the positional budget.
|
|
158
|
+
required_positional = [
|
|
159
|
+
param
|
|
160
|
+
for param in positional_params
|
|
161
|
+
if param.default is inspect.Parameter.empty
|
|
162
|
+
and not (param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD and param.name in keywords)
|
|
163
|
+
]
|
|
164
|
+
if len(required_positional) > positional:
|
|
165
|
+
return False
|
|
166
|
+
required_keyword_only = [
|
|
167
|
+
param
|
|
168
|
+
for param in params
|
|
169
|
+
if param.kind is inspect.Parameter.KEYWORD_ONLY and param.default is inspect.Parameter.empty
|
|
170
|
+
]
|
|
171
|
+
kwargs_covered = var_keyword or all(param.name in keywords for param in required_keyword_only)
|
|
172
|
+
return kwargs_covered
|
|
173
|
+
|
|
174
|
+
|
|
106
175
|
def preflight_agents(
|
|
107
176
|
invocation: AmsContext,
|
|
108
177
|
target: TenantTarget | None = None,
|
|
@@ -131,6 +200,13 @@ def preflight_agents(
|
|
|
131
200
|
raise click.ClickException(
|
|
132
201
|
"SDK missing ams_admin agent lifecycle support; use the reviewed SDK version required by this CLI."
|
|
133
202
|
)
|
|
203
|
+
for method in methods:
|
|
204
|
+
call = _AGENT_METHOD_CALLS.get(method)
|
|
205
|
+
if call is not None and not _signature_binds(getattr(admin, method), *call):
|
|
206
|
+
raise click.ClickException(
|
|
207
|
+
f"Installed SDK ams_admin {method} does not accept the call this CLI makes; use the "
|
|
208
|
+
"reviewed SDK version required by this CLI. Nothing was sent."
|
|
209
|
+
)
|
|
134
210
|
finally:
|
|
135
211
|
sdk.close()
|
|
136
212
|
|
|
@@ -12,7 +12,7 @@ from pydantic import ValidationError
|
|
|
12
12
|
|
|
13
13
|
from ..output import output_mode
|
|
14
14
|
from .domain_errors import DomainReadRefused
|
|
15
|
-
from .pretty import error_line,
|
|
15
|
+
from .pretty import error_line, outcome_details, success_text
|
|
16
16
|
from .results import failed
|
|
17
17
|
|
|
18
18
|
_MESSAGE_TEMPLATES = {
|
|
@@ -76,7 +76,8 @@ _AMS_READ_DOMAINS = {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def
|
|
79
|
+
def http_category(operation: str, status: object) -> str:
|
|
80
|
+
"""The bounded category for one HTTP status (writes reuse the same mapping)."""
|
|
80
81
|
shared_categories = {
|
|
81
82
|
401: "authentication",
|
|
82
83
|
403: "authorization",
|
|
@@ -115,7 +116,7 @@ def _classification(operation: str, error: BaseException) -> tuple[str, int]:
|
|
|
115
116
|
if isinstance(error, DomainReadRefused):
|
|
116
117
|
return error.category, 1
|
|
117
118
|
if isinstance(error, (HealthCloudHTTPError, SDKError)):
|
|
118
|
-
return
|
|
119
|
+
return http_category(operation, getattr(error, "status_code", None)), 1
|
|
119
120
|
if isinstance(error, (HealthCloudNetworkError, NetworkError)):
|
|
120
121
|
return "transport", 1
|
|
121
122
|
if isinstance(error, click.ClickException):
|
|
@@ -171,7 +172,7 @@ def _render_failure(envelope: dict, mode: str) -> None:
|
|
|
171
172
|
# An unknown mutation outcome carries the read-only reconciliation the
|
|
172
173
|
# operator must see before acting again; pretty is the default mode, so
|
|
173
174
|
# dropping it here would only expose it under ``--output json``.
|
|
174
|
-
details =
|
|
175
|
+
details = outcome_details(envelope)
|
|
175
176
|
if details:
|
|
176
177
|
click.echo(details, err=True)
|
|
177
178
|
return
|
|
@@ -272,25 +272,38 @@ def _detail_lines(mapping: dict, depth: int = 0) -> list[str]:
|
|
|
272
272
|
return lines
|
|
273
273
|
|
|
274
274
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
275
|
+
# Outcomes that carry a written-mutation evidence block. ``unknown`` means the
|
|
276
|
+
# write may have committed and ``failed`` means it was provably sent but the
|
|
277
|
+
# readback (or the server's refusal) says it did not land: both must show the
|
|
278
|
+
# operator what was sent and what the saved state is. A plain read failure has
|
|
279
|
+
# no such block and stays one line.
|
|
280
|
+
_EVIDENCED_OUTCOMES = ("unknown", "failed")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def outcome_details(envelope: dict) -> str:
|
|
284
|
+
"""Render the data and verification a sent-mutation envelope carries.
|
|
285
|
+
|
|
286
|
+
An unknown mutation outcome may already have committed, and a failed one was
|
|
287
|
+
provably sent, so the default (pretty) mode must show the read-only
|
|
288
|
+
reconciliation, ``mutation_replayed: false`` and the ``saved_state`` the
|
|
289
|
+
operator needs before acting again, not just the bounded error line.
|
|
290
|
+
|
|
291
|
+
The block is gated on the envelope carrying ``data`` evidence, not on the
|
|
292
|
+
outcome alone: a local refusal built by ``failed(..., read_only=False)``
|
|
293
|
+
(every ``run_domain(read_only=False)`` refusal: teams, policies,
|
|
294
|
+
departments, guardrails, agents) was never sent, has no evidence to show,
|
|
295
|
+
and stays one line even though it carries a ``not_applicable`` verification.
|
|
282
296
|
"""
|
|
283
297
|
|
|
284
|
-
if envelope.get("outcome")
|
|
298
|
+
if envelope.get("outcome") not in _EVIDENCED_OUTCOMES:
|
|
285
299
|
return ""
|
|
286
|
-
lines: list[str] = []
|
|
287
300
|
data = envelope.get("data")
|
|
288
|
-
if isinstance(data, dict)
|
|
289
|
-
|
|
301
|
+
if not isinstance(data, dict) or not data:
|
|
302
|
+
return ""
|
|
303
|
+
lines = _detail_lines(data)
|
|
290
304
|
verification = envelope.get("verification")
|
|
291
305
|
if isinstance(verification, dict) and verification:
|
|
292
|
-
|
|
293
|
-
lines.append("")
|
|
306
|
+
lines.append("")
|
|
294
307
|
lines.append("verification:")
|
|
295
308
|
lines.extend(_detail_lines(verification, 1))
|
|
296
309
|
return "\n".join(lines)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "healthcloud-cli"
|
|
7
|
-
version = "3.7.
|
|
7
|
+
version = "3.7.9"
|
|
8
8
|
description = "Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.14"
|
|
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
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/__init__.py
RENAMED
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/commands.py
RENAMED
|
File without changes
|
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/operations.py
RENAMED
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/data_sources/projections.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/operations.py
RENAMED
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/departments/projections.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/operations.py
RENAMED
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/guardrails/projections.py
RENAMED
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/__init__.py
RENAMED
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/commands.py
RENAMED
|
File without changes
|
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/operations.py
RENAMED
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/ams/organization/projections.py
RENAMED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{healthcloud_cli-3.7.8 → healthcloud_cli-3.7.9}/healthcloud_cli/contracts/mcp-tools.manifest.json
RENAMED
|
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
|