diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 3a8caaa99..07f58ef58 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -8,7 +8,7 @@ for current product capability. |---|---|---| | [WS-DB-002](initiatives/WS-DB-002/OVERVIEW.md) | Complete | Shared UUIDv7 record generation, native-UUID relationships and fresh v0.1 baseline; natural-owner retry custody and aligned CI/local setup | | [WS-MCP-002](initiatives/WS-MCP-002/OVERVIEW.md) | Planned | Nine tools through WS-MCP-002-03: self-service and administrative reads; 18 tools remain and WS-MCP-002-04 administrative grant mutations are next | -| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, inspection, task browsing/claim/start, governing context, project creation/declaration and original upload delivered through WS-CLI-001-10; PILOT-13 assigned-guide listing/download is delivered; setup inspection/approval/activation, further public journeys and binary distribution remain; hidden submission is not exposed | +| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, inspection, task browsing/claim/start, governing context, project creation/declaration, original upload and setup diagnostic inspection delivered through WS-CLI-001-11; PILOT-13 assigned-guide listing/download is delivered; approval/activation, further public journeys and binary distribution remain; hidden submission is not exposed | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Source storage, inert AUTH contracts, TASK request reservation, hidden exact AUTH preparation and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; TASK-before-CHECKERS reservation/current-read custody and ordered admission INSERTs are delivered by 04E1B-B1; 04E1B-B2 adds exact source preparation without publication; 04E1B-B3 retains inspected ZIP metadata and B4 binds Submission text to checked packet custody and B5 validates bounded evaluation content before durable admission; B6 commits initial Submission/dispatch and exact replay; B7 adds hidden request delivery; completion routing is next under the [first-layer sequence](initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone), ending in a public contributor drill before live human review/revision. True admission remains independent of shared acceptance; false activation still requires exact AUTH receipt custody, database complete-set enforcement, currentness race proof and atomic routing activation. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Exact checker input/output custody and packet foundations are delivered; routing integration, remediation and public intake remain. | | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | AUTH-19A commitments, TASK request reservation, ARCH-04E2-A hidden strict PREP matching and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; the action remains unavailable, with mandatory exact AUTH receipt input, database closure, audit/outbox and scoped activation still required for the automated path. | diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 19f5e1ec5..0357b2a21 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -15,6 +15,7 @@ [WS-CLI-001-08](WS-CLI-001-08.md), draft project shell creation with explicit caller-owned replay; [WS-CLI-001-09](WS-CLI-001-09.md), guide declaration, illustrative tasks and document upload selectors; [WS-CLI-001-10](WS-CLI-001-10.md), declared-original upload with hash/size-bound storage receipts; + [WS-CLI-001-11](WS-CLI-001-11.md), latest exact-guide setup diagnostics and compilation lineage; [PILOT-13](../../changes/pilot13-assigned-task-guide-documents.md), assigned-task locked-guide listing/download. ## Current boundary @@ -53,6 +54,9 @@ before success. Its exact-byte receipt establishes storage only, not setup readi approval or activation; unconfirmed outcomes require deliberate unchanged-input replay. `task guide TASK_ID [--download DIR]` lists or downloads the assigned task's locked originals with verified byte identity; setup examples stay private. +`project guide setup PROJECT_ID GUIDE_ID` reads the latest exact-guide setup +and compilation lineage through one public GET. It does not poll, execute setup, +approve policies or activate the guide; successful reading is not readiness. All have text/JSON output and built-binary integration proof. Mutations preserve omitted/null semantics and explicitly report uncertain outcomes without automatic retries. @@ -111,12 +115,15 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. Setup awaits actual original upload. 10. **WS-CLI-001-10:** Upload one declared PDF/DOCX/PPTX original through public binary POST; validate storage receipt against local bytes and preserve manual - replay custody. Setup inspection, approval and activation remain. -11. **Later governed-work commands:** Add further project setup, submission, + replay custody. Approval and activation remain. +11. **WS-CLI-001-11:** Inspect latest exact-guide setup and compilation lineage + through the public diagnostic read. No polling, local readiness rules, + execution, approval or activation. +12. **Later governed-work commands:** Add further project setup, submission, review, revision, and contribution reads/writes only as their actual public contracts and authority boundaries become available. Split by user journey, not one PR per endpoint or one giant catalogue PR. -12. **Optional TUI:** Add a focused public queue/evidence view after its API +13. **Optional TUI:** Add a focused public queue/evidence view after its API workflow is complete. Never require a TUI for agents or scripts. ## Risks and proof diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-11.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-11.md new file mode 100644 index 000000000..0e7fb8203 --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-11.md @@ -0,0 +1,88 @@ +# WS-CLI-001-11 — Inspect the latest guide setup + +- Initiative: `WS-CLI-001` +- Durable disposition: `Complete` +- Intended merge outcome: Inspect one guide's latest server-owned setup and compilation lineage through its existing public read. + +## Intent + +Guide declaration returns an initial receipt and original upload establishes +storage, neither current setup readiness. Add `workstream project guide setup +PROJECT_ID GUIDE_ID`, calling only public `GET +/api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest` once, without +preflight, polling, local readiness rules, or follow-up reads. Return the exact +validated API object for JSON and safely escaped complete fields for text. +An inspected blocked, pending, or failed setup is a successful read, not a +successful compilation, approved policy, or activated guide. + +Reuse the hardened JSON client, UUID-identity comparison, closed response +decoder and complete escaped-object renderer. Validate required versus +nullable fields against `ProjectSetupRunResponse`; the three nullable lineage +UUIDs with default null may be omitted. Bind project/guide identity, validate +record UUIDs and timestamps, and retain nullable diagnostics. Setup generation +is a backend integer, so preserve its range without an int64 ceiling. Status, +step, Celery selector and diagnostic strings are backend-owned strings, not +new client enums or decisions. Reject unknown/duplicate fields, required nulls, +malformed identities/times and substituted project/guide identities with empty +stdout. No new transport, schema owner, dependencies or credential mechanism. + +## Bounded change + +Allowed: new `cli/internal/api/guide_setup.go`, command registration in +`cli/internal/command/guide_create.go`, new +`cli/tests/integration/test_guide_setup_http.py`, the existing real bootstrap +journey in `cli/tests/integration/guide_create_journey.py`, CLI/root README, +affected roadmap claims, this record, initiative overview and index. + +Prohibited: backend/MCP/workflow/dependency changes, hidden routes, setup +execution/retry, report/proposal fetching, approval/activation, local policy or +authorization decisions, file storage, additional broad test fixtures and TUI. +The existing setup diagnostic authority owns exact project/guide membership +and fresh actor/grant checks; no cached authorization or management inference. + +## Acceptance criteria + +- Built-process HTTP proof captures the unchanged escaped UUID selectors and + bearer, one GET per invocation, no body/key or follow-up. Supported alternate + UUID spellings compare by identity. JSON retains the exact response; text + escapes diagnostic controls and includes nullable lineage without claiming + readiness. Pending, provider-uncertain, failed and finalized projections remain + observations. Omitted default-null lineage UUIDs and integers beyond int64 + work; malformed/substituted/null/duplicate/unknown replies do not. +- Exercise bounded replies, canonical denial, reflected credential error + metadata and redirects using existing process fixtures. Existing shared + transport tests retain timeout/proxy/HTTP2 custody; no duplicate transport suite. +- Extend the existing real Flow-token/bootstrap/PostgreSQL public journey: + verify the read is in OpenAPI; compare CLI and direct API setup objects after + declaration; exact scoped manager succeeds, outsider/foreign project fail, + and revocation/suspension after a valid control deny the same setup read. + This proves current diagnostic reads, not live provider execution. +- Run all CLI process/API tests through `run_isolated_tests.py` and confirm + database cleanup, Go build/vet/module verification/tidy-diff, Ruff/format, + links/stale-wording/Commitrail, and full required hosted Backend. No test + removal, skips, coverage quotas or CI weakening. +- Mutate the response identity guard in an out-of-tree copy and demonstrate + the named substitution test fails at the intended assertion. + +## Evidence + +```sh +cd cli +go mod verify +go mod tidy -diff +go vet ./... +go build -trimpath -o /tmp/workstream-cli ./cmd/workstream +cd ../backend +WORKSTREAM_CLI_EXECUTABLE=/tmp/workstream-cli .venv/bin/python scripts/run_isolated_tests.py --metadata-json /tmp/workstream-cli-isolation.json --timeout-seconds 900 -- .venv/bin/python -m pytest -q ../cli/tests/integration +``` + +## Risk and review routing + +L1, bounded protected public diagnostic response; no invented urgency or human +decision beyond approval of this PR. Focused plan feasibility review first. +After shared proof and a clean candidate, route architecture/documentation/reuse, +security and QA/test-delta in three bounded assignments, not all nine reviewers. +Human focus: this command observes latest setup, not stored creation state; +statuses and lineage do not establish approval, activation or permission for +another operation. Runtime deployment and binary distribution remain separate. +Roadmap impact is the CLI's newly usable read, not backend lifecycle completion. diff --git a/README.md b/README.md index 3c32c7faf..4444d3070 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,9 @@ upload documents or approve the guide; returned setup waits for those documents. --media-type MIME --idempotency-key UUID` uploads a declared original. The CLI streams its bytes and validates the storage receipt against their hash and size. An upload receipt is not setup completion, policy approval or guide activation. +`workstream project guide setup PROJECT_ID GUIDE_ID` reads the latest public +setup status and compilation lineage. A successful diagnostic read is not +approval or activation; there is no polling or setup execution in the CLI. All support human-readable and JSON output, using the caller's Flow token. The first source package is buildable; further workflow commands and published binaries remain planned. diff --git a/cli/README.md b/cli/README.md index c36e7d369..fedb4a3bb 100644 --- a/cli/README.md +++ b/cli/README.md @@ -15,6 +15,7 @@ context/intake requirements: | `workstream project create --name TEXT --slug TEXT --idempotency-key UUID` | `POST /api/v1/projects` | | `workstream project guide create PROJECT_ID --input FILE --idempotency-key UUID` | `POST /api/v1/projects/PROJECT_ID/guides` | | `workstream project guide upload PROJECT_ID GUIDE_ID DOCUMENT_ID --file FILE --media-type MIME --idempotency-key UUID` | `POST /api/v1/projects/PROJECT_ID/guides/GUIDE_ID/documents/DOCUMENT_ID/content` | +| `workstream project guide setup PROJECT_ID GUIDE_ID` | `GET /api/v1/projects/PROJECT_ID/guides/GUIDE_ID/setup-runs/latest` | | `workstream project tasks PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/tasks` | | `workstream project task PROJECT_ID TASK_ID` | `GET /api/v1/projects/PROJECT_ID/tasks/TASK_ID` | | `workstream task ready PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/tasks/ready` | @@ -188,6 +189,28 @@ unknown outcomes. An otherwise valid unconfirmed-storage status also exits 1, sets `outcome_unknown` and leaves stdout empty rather than reporting success. Diagnostics never include file paths, contents or raw provider/transport errors. +## Inspect current guide setup + +```sh +workstream project guide setup PROJECT_ID GUIDE_ID --output json +``` + +This reads the latest setup for that exact guide, rather than replaying its +initial creation receipt. The API owns current scoped diagnostic authority, +actor lifecycle, project/guide membership and compilation lineage. The CLI +makes one public GET with the caller's bearer; no polling, role preflight, +follow-up calls, setup execution or retry is added. + +JSON output preserves the complete validated API object. Text renders all +fields with terminal escaping, including nullable diagnostic and compilation +selectors. UUID identities and timestamps are checked; the generation integer +retains the backend response range. A pending, blocked, failed or finalized +setup can be read successfully (exit 0); this is not a successful compilation, +policy approval, guide activation or authority for another operation. +Denials, malformed/substituted replies, oversized JSON and network failures +leave stdout empty and exit 1. The normal 12-second/64KiB JSON bounds apply. +Approval and activation commands remain separate future work. + ## Create a draft project shell ```sh diff --git a/cli/internal/api/guide_setup.go b/cli/internal/api/guide_setup.go new file mode 100644 index 000000000..71c168e10 --- /dev/null +++ b/cli/internal/api/guide_setup.go @@ -0,0 +1,83 @@ +package api + +import ( + "context" + "errors" + "math/big" + "net/http" + "net/url" +) + +// GuideSetup is a current diagnostic projection, not an approval or activation. +type GuideSetup struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + GuideID string `json:"guide_id"` + GuideVersion string `json:"guide_version"` + SourceSnapshotID string `json:"source_snapshot_id"` + SetupGeneration *big.Int `json:"setup_generation"` + FinalizedCompilationID *string `json:"finalized_compilation_id"` + CorrectionOperationID *string `json:"correction_operation_id"` + PredecessorCompilationID *string `json:"predecessor_compilation_id"` + CeleryTaskID *string `json:"celery_task_id"` + DocumentsReadyAt *string `json:"documents_ready_at"` + Status string `json:"status"` + CurrentStep string `json:"current_step"` + OutputSufficiencyReportID *string `json:"output_sufficiency_report_id"` + OutputSubmissionArtifactPolicyID *string `json:"output_submission_artifact_policy_id"` + ErrorCode *string `json:"error_code"` + ErrorArtifactIncidentID *string `json:"error_artifact_incident_id"` + ErrorSummary *string `json:"error_summary"` + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt *string `json:"started_at"` + FinishedAt *string `json:"finished_at"` +} + +func (c *Client) GuideSetup(ctx context.Context, project, guide string) (Result[GuideSetup], error) { + var result Result[GuideSetup] + if !validUUID(project) || len(project) > 100 || !validUUID(guide) || len(guide) > 100 { + return result, errors.New("PROJECT_ID and GUIDE_ID must be UUIDs") + } + raw, err := c.request(ctx, http.MethodGet, + "/api/v1/projects/"+url.PathEscape(project)+"/guides/"+url.PathEscape(guide)+"/setup-runs/latest", "", nil) + if err != nil { + return result, err + } + var value GuideSetup + fields, err := contextObject(raw, &value, []string{ + "id", "project_id", "guide_id", "guide_version", "source_snapshot_id", "setup_generation", + "status", "current_step", "created_by", "created_at", "updated_at", + }, nil) + if err != nil || !sameUUID(value.ProjectID, project) || !sameUUID(value.GuideID, guide) || + !validUUID(value.ID) || !validUUID(value.SourceSnapshotID) || !validUUID(value.CreatedBy) || + !validTime(value.CreatedAt) || !validTime(value.UpdatedAt) { + return result, &Failure{Code: "invalid_api_response"} + } + // These public nullable fields are required; only the three lineage UUIDs + // default to null when omitted. Do not infer a state machine from diagnostics. + for _, name := range []string{ + "celery_task_id", "documents_ready_at", "output_sufficiency_report_id", + "output_submission_artifact_policy_id", "error_code", "error_artifact_incident_id", + "error_summary", "started_at", "finished_at", + } { + if _, present := fields[name]; !present { + return result, &Failure{Code: "invalid_api_response"} + } + } + for _, identity := range []*string{ + value.FinalizedCompilationID, value.CorrectionOperationID, value.PredecessorCompilationID, + value.OutputSufficiencyReportID, value.OutputSubmissionArtifactPolicyID, value.ErrorArtifactIncidentID, + } { + if identity != nil && !validUUID(*identity) { + return result, &Failure{Code: "invalid_api_response"} + } + } + for _, timestamp := range []*string{value.DocumentsReadyAt, value.StartedAt, value.FinishedAt} { + if timestamp != nil && !validTime(*timestamp) { + return result, &Failure{Code: "invalid_api_response"} + } + } + return Result[GuideSetup]{Raw: raw, Value: value}, nil +} diff --git a/cli/internal/command/guide_create.go b/cli/internal/command/guide_create.go index 2919b1eb9..d62d47d0b 100644 --- a/cli/internal/command/guide_create.go +++ b/cli/internal/command/guide_create.go @@ -36,6 +36,23 @@ func addGuideCreate(project *cobra.Command, client func() (*api.Client, error), create.Flags().StringVar(&key, "idempotency-key", "", "Required caller-owned UUID; retain with unchanged input for manual replay") guide.AddCommand(create) addGuideUpload(guide, client, output, stdout) + guide.AddCommand(&cobra.Command{ + Use: "setup PROJECT_ID GUIDE_ID", Short: "Inspect latest guide setup (does not approve or activate)", Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := client() + if err != nil { + return err + } + result, err := apiClient.GuideSetup(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + if *output == "json" { + return writeJSON(stdout, result.Raw) + } + return writeTaskContextFields(stdout, []contextField{{"Latest setup (not approval or activation)", result.Value}}) + }, + }) project.AddCommand(guide) } diff --git a/cli/tests/integration/guide_create_journey.py b/cli/tests/integration/guide_create_journey.py index 7094d2f5e..e892f5bef 100644 --- a/cli/tests/integration/guide_create_journey.py +++ b/cli/tests/integration/guide_create_journey.py @@ -9,6 +9,12 @@ async def exercise_guide_creation( ): specification = (await direct.get("/openapi.json", timeout=60)).json() assert "post" in specification["paths"]["/api/v1/projects/{project_id}/guides"] + assert ( + "get" + in specification["paths"][ + "/api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest" + ] + ) actor = profiles["cli-project-manager"]["actor_profile_id"] token = tokens["cli-project-manager"] admin = {"Authorization": f"Bearer {tokens['cli-admin']}"} @@ -130,6 +136,22 @@ async def lifecycle(transition): setup.json()["project_id"] == project_id and setup.json()["guide_id"] == created["id"] ) + + def inspect_setup(*, project=project_id, presented=token): + return cli( + origin, + presented, + "-o", + "json", + "project", + "guide", + "setup", + project, + created["id"], + ) + + assert positive(inspect_setup()) == setup.json() + denial(inspect_setup(presented=tokens["cli-outsider"])) recovered = await direct.post( f"/api/v1/projects/{project_id}/guides", headers=caller | {"Idempotency-Key": key}, @@ -151,6 +173,7 @@ async def lifecycle(transition): ) assert foreign.status_code == 201, foreign.text denial(create(body, str(uuid4()), foreign.json()["id"])) + denial(inspect_setup(project=foreign.json()["id"])) collision = body | { "version": "collision-" + uuid4().hex, "documents": [ @@ -163,12 +186,16 @@ async def lifecycle(transition): # A positive immediately before revocation discriminates a real authority # change from a never-authorized caller. Guide replay rechecks authority. positive(create(body | {"version": "before-revoke-" + uuid4().hex}, str(uuid4()))) + assert positive(inspect_setup()) == setup.json() await revoke(grant_id) + denial(inspect_setup()) denial(create(body, key)) denial(create(body | {"version": "revoked-" + uuid4().hex}, str(uuid4()))) grant_id = await grant() positive(create(body | {"version": "before-suspend-" + uuid4().hex}, str(uuid4()))) + assert positive(inspect_setup()) == setup.json() await lifecycle("suspend") + denial(inspect_setup()) denial(create(body, key)) denial(create(body | {"version": "suspended-" + uuid4().hex}, str(uuid4()))) await lifecycle("reactivate") diff --git a/cli/tests/integration/test_guide_setup_http.py b/cli/tests/integration/test_guide_setup_http.py new file mode 100644 index 000000000..9bbbecc94 --- /dev/null +++ b/cli/tests/integration/test_guide_setup_http.py @@ -0,0 +1,241 @@ +"""Built-process setup observations through the one existing public GET.""" + +from copy import deepcopy +import json +from urllib.parse import quote + +from test_contributor_task_mutations import canonical_error +from test_guide_create_http import GUIDE, SETUP +from test_http_boundary import ( + ACTOR, + PROFILE, + PROJECT, + TOKEN, + assert_failure, + http_fixture, +) + +UUID = "018f0ebc-7966-7e8d-bc4d-1cae1e00000a" +DEFAULT_NULL = ( + "finalized_compilation_id", + "correction_operation_id", + "predecessor_compilation_id", +) +UUID_FIELDS = ( + "id", + "project_id", + "guide_id", + "source_snapshot_id", + "created_by", + *DEFAULT_NULL, + "output_sufficiency_report_id", + "output_submission_artifact_policy_id", + "error_artifact_incident_id", +) +TIME_FIELDS = ( + "created_at", + "updated_at", + "documents_ready_at", + "started_at", + "finished_at", +) +REQUIRED_NONNULL = ( + "id", + "project_id", + "guide_id", + "guide_version", + "source_snapshot_id", + "setup_generation", + "status", + "current_step", + "created_by", + "created_at", + "updated_at", +) + + +def setup(): + return { + "id": SETUP, + "project_id": PROJECT, + "guide_id": GUIDE, + "guide_version": "guide é\n\x1b[31m", + "source_snapshot_id": UUID, + "setup_generation": 1, + **dict.fromkeys(DEFAULT_NULL), + "celery_task_id": None, + "documents_ready_at": None, + "status": "awaiting_documents", + "current_step": "document_intake", + "output_sufficiency_report_id": None, + "output_submission_artifact_policy_id": None, + "error_code": None, + "error_artifact_incident_id": None, + "error_summary": None, + "created_by": ACTOR, + "created_at": PROFILE["created_at"], + "updated_at": PROFILE["updated_at"], + "started_at": None, + "finished_at": None, + } + + +def invoke(cli, origin, project=PROJECT, guide=GUIDE, *, output="json", token=TOKEN): + return cli(origin, token, "-o", output, "project", "guide", "setup", project, guide) + + +def test_guide_setup_preserves_selectors_complete_json_and_safe_text(cli): + with http_fixture() as (origin, response, requests): + value = setup() + for status in ( + "awaiting_documents", + "provider_outcome_unresolved", + "compilation_invalid_terminal", + "completed", + ): + value["status"] = status + if status != "awaiting_documents": + value |= { + "celery_task_id": "guide-compile:" + SETUP + ":1", + "documents_ready_at": PROFILE["created_at"], + "started_at": PROFILE["created_at"], + "error_summary": "Diagnostic é\n\x1b[32m\u202e", + "error_code": "provider_outcome_unresolved", + "error_artifact_incident_id": UUID, + } + if status == "completed": + value |= dict.fromkeys(DEFAULT_NULL, UUID) + value |= { + "output_sufficiency_report_id": UUID, + "output_submission_artifact_policy_id": UUID, + "finished_at": PROFILE["updated_at"], + } + response["body"] = json.dumps(value, ensure_ascii=False, indent=2).encode() + for spell in ( + lambda x: x, + lambda x: x.replace("-", ""), + lambda x: "{" + x + "}", + lambda x: "urn:uuid:" + x, + ): + project, guide = spell(PROJECT), spell(GUIDE) + result = invoke(cli, origin, project, guide) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert result.stdout.strip().encode() == response["body"] + assert requests[-1] == ( + "GET", + f"/api/v1/projects/{quote(project, safe=':')}/guides/{quote(guide, safe=':')}/setup-runs/latest", + "Bearer " + TOKEN, + ) + text = invoke(cli, origin, output="text") + assert text.returncode == 0 and text.stderr == "", text.stderr + assert text.stdout.startswith("Latest setup (not approval or activation): ") + assert text.stdout.count("\n") == 1 + assert "\x1b" not in text.stdout and "\u202e" not in text.stdout + for name in value: + assert f'"{name}"' in text.stdout + assert len(requests) == 20 # One read each, no readiness polling/preflight. + assert response["commands"] == [] and response["updates"] == [] + + +def test_guide_setup_nullable_defaults_and_backend_integer_range(cli): + with http_fixture() as (origin, response, _requests): + for generation in (1, 9223372036854775807, 9223372036854775808): + value = setup() | {"setup_generation": generation} + for key in DEFAULT_NULL: + del value[key] + response["body"] = json.dumps(value).encode() + result = invoke(cli, origin) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert json.loads(result.stdout) == value + text = invoke(cli, origin, output="text") + assert text.returncode == 0 and str(generation) in text.stdout + for invalid in ('"1"', "1.5", "1e2", "true", "[]", "{}"): + response["body"] = ( + json.dumps(setup()) + .replace('"setup_generation": 1', '"setup_generation": ' + invalid) + .encode() + ) + assert_failure(invoke(cli, origin), "invalid_api_response") + + +def test_guide_setup_rejects_required_nulls_missing_and_unknown_fields(cli): + value = setup() + with http_fixture() as (origin, response, _requests): + for name in REQUIRED_NONNULL: + response["body"] = json.dumps(value | {name: None}).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + for name in value.keys() - set(DEFAULT_NULL): + missing = deepcopy(value) + del missing[name] + response["body"] = json.dumps(missing).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + for body in ( + json.dumps(value | {"provider_secret": "must-not-be-output"}), + json.dumps(value)[:-1] + ', "status": "approved"}', + "null", + "[]", + ): + response["body"] = body.encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + + +def test_guide_setup_rejects_malformed_and_substituted_identity(cli): + with http_fixture() as (origin, response, _requests): + for name in UUID_FIELDS: + response["body"] = json.dumps(setup() | {name: "not-uuid"}).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + for name in ("project_id", "guide_id"): + response["body"] = json.dumps(setup() | {name: UUID}).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + for name in TIME_FIELDS: + response["body"] = json.dumps(setup() | {name: "not-time"}).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + for name in ( + "guide_version", + "status", + "current_step", + "celery_task_id", + "error_code", + "error_summary", + ): + response["body"] = json.dumps(setup() | {name: {"invalid": True}}).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + + +def test_guide_setup_invalid_selectors_send_no_request(cli): + with http_fixture() as (origin, _response, requests): + for project, guide in ( + ("../other", GUIDE), + (PROJECT, "../other"), + ("", GUIDE), + (PROJECT, "x" * 101), + ): + assert_failure(invoke(cli, origin, project, guide), "invalid_arguments", 2) + assert requests == [] + + +def test_guide_setup_bounds_denials_redirects_and_credential_metadata(cli): + with http_fixture() as (origin, response, requests): + response["body"] = json.dumps( + setup() | {"error_summary": "x" * (64 * 1024)} + ).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + response["headers"]["Content-Type"] = "text/plain" + response["body"] = json.dumps(setup()).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + response["headers"]["Content-Type"] = "application/json" + response["status"] = 404 + response["body"] = json.dumps(canonical_error("not_found")).encode() + denied = invoke(cli, origin) + assert_failure(denied, "not_found") + assert json.loads(denied.stderr)["error"]["status"] == 404 + assert "outcome_unknown" not in json.loads(denied.stderr)["error"] + response["body"] = json.dumps(canonical_error("credential_canary_AAA")).encode() + assert_failure(invoke(cli, origin, token="credential_canary_AAA"), "api_error") + response["status"] = 302 + response["headers"]["Location"] = origin + "/credential-sink" + response["body"] = b"" + count = len(requests) + assert_failure(invoke(cli, origin), "redirect_refused") + assert len(requests) == count + 1 + assert not any(request[1] == "/credential-sink" for request in requests) diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 1a189d805..ae122d40a 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -150,7 +150,8 @@ draft project creation, guide declaration with illustrative tasks/document selectors, and declared-original upload with hash/size-bound storage receipts and a local-original recheck before success, using explicit caller-owned retry keys, with -text/JSON output. Project inspection preserves +text/JSON output, plus latest guide-setup diagnostic inspection without polling +or local readiness decisions. Project inspection preserves server-selected full/minimal fields; no public project-list route is invented. CLI write uncertainty is explicit and never automatically retried. These source packages do not claim @@ -362,6 +363,10 @@ cannot be reused as post-submission review-gate evidence. See the streaming, explicit replay custody and SHA-256/size-bound storage receipts. Unconfirmed storage is an unknown outcome, not success. Upload does not imply setup completion, policy approval or guide activation. + [CLI setup inspection](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-11.md) + reads the latest exact-guide setup and compilation lineage through the public + diagnostic GET. Read success does not establish compilation success, policy + approval or guide activation; no polling or setup execution is added. [PILOT-13 assigned-guide access](../.commitrail/changes/pilot13-assigned-task-guide-documents.md) lists exact locked originals in assigned contributor context and streams authorized, fully verified ART bytes. `task guide` lists or safely downloads