diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 14da5f591..25092aff6 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -9,7 +9,7 @@ for current product capability. | [WS-AGENT-001](initiatives/WS-AGENT-001/OVERVIEW.md) | Planned | Neutral document tools plus OpenAI Agents SDK and OmniCoreAgent original-guide qualifications are delivered through the same public API, durable worker, parser and MinIO path. Retain the exact external provider/account and selected-image AWS evidence, then prove the broader hosted Flow contribution journey before deployment claims. | | [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 | Forty-seven tools through WS-MCP-002-10 cover the original access/setup catalogue plus contributor workspace, contribution evidence and contribution policies; four larger domain batches and assembled release proof remain. | -| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI setup, contributor/manager workflows, administrative and operational commands, Finance policies and contribution exports are detailed in the overview; -43 completes the artifact Operator client and proves the connected local unpaid automated-acceptance journey. -44 prepares four native release archives and a gated manual publisher; protected environment and immutable-tag ruleset verification, first tag/publication and public-asset audit remain. Public human-review controls, hosted proof and optional TUI are separate. Current revision context never substitutes for historical evidence. | +| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI setup, contributor/manager workflows, administrative and operational commands, Finance policies and contribution exports are detailed in the overview; -43 proves the connected local unpaid automated-acceptance journey. -44 prepares four native release archives and a gated manual publisher; -47 hardens its administrator-bypass guard. Protected release-environment and immutable-tag ruleset configuration/verification, first publication and public-asset audit remain. -46 adds the separate system-reader project catalogue; -48 adds the opt-in read-only contributor TUI. Detailed findings/Evidence remain manual CLI handoffs; TUI writes/management, public human-review controls and hosted proof are separate. Current revision context never substitutes for historical evidence. | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Public admission-backed initial/remediation Submission entry and supported false-policy guide activation are implemented over selected production delivery. New false/pass effects require LIVE REV; a known pre-effect non-live refusal has bounded retry, with exhausted delivery dead-lettered. The [bounded local public/worker drill](initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone) starts from public guide inputs with recorded inference; hosted operation and live human review remain. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Verified input/output custody, exact-cause replacement and public admission-backed ZIP/Submission entry are delivered. The bounded local public/worker drill passed; hosted operation and setup-fault handling remain separate. | | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Exact fixed-router custody, checker remediation and checker retry are delivered. The ART Operator backend prerequisite adds exact system-Operator artifact reads and independently guarded verification retry; WS-CLI-001-43 consumes all nine public ART operations, while hosted S3 proof remains separate. Public entry and supported false-guide activation use current exact AUTH/SQL custody; human decision authority remains later work. | diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 5bfc6aa10..e7bdb289b 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -58,6 +58,13 @@ integrity metadata and a separately gated manual GitHub Releases publisher; the environment and immutable tag ruleset remain external prerequisites. It does not claim a tag, published binary or signed/notarized artifact. +[WS-CLI-001-48](WS-CLI-001-48.md) adds the opt-in read-only contributor +workbench: exact-grant projects, one-page Ready/active Mine/own Submissions, +fresh details and explicit Mine context/intake reads. Detailed findings and +contribution evidence remain inert CLI handoffs. All mutations, management +screens and operation-specific recovery remain separate TUI boundaries; this +interface does not depend on first binary publication or system-wide discovery. + ## Current boundary [WS-CLI-001-26](WS-CLI-001-26.md) adds deliberate draft-guide summary editing @@ -66,7 +73,7 @@ and separate guide-bound review/revision policy configuration. The commands the existing public writes with caller-owned replay keys and policy `If-Match`. The backend owns manager authorization for new writes, policy semantics, replay and activation; editing/configuring a guide does not approve or activate it. Other public API -client gaps and the optional TUI remain separate work. Binary distribution's +client gaps and the broader TUI beyond -48 remain separate work. Binary distribution's workflow is prepared by -44; a protected release run and public-asset audit remain operational work. @@ -316,14 +323,15 @@ that human's credential, not a registered agent identity. Make every operation complete without a terminal: stable JSON output, safe errors, exit status, and no required prompts. Add optional terminal assistance -only where it improves an actual workflow. A future `workstream ui` can browse -public queues and evidence when enough of that lifecycle is ready; it must use -the same client operations and never become a second state machine. Prefer +only where it improves an actual workflow. The opt-in `workstream tui` browses +the bounded contributor reads in -48 using the same client operations and never +becomes a second state machine. Further screens require stable public contracts +and their own proof. Prefer purposeful commands over generating a flag mirror of every OpenAPI route. Go is the chosen implementation language. Cobra owns command parsing/help and -completion. Bubble Tea is a candidate for a later, bounded TUI change, not a -dependency of the foundation. Do not add Python, TypeScript, or Rust duplicate +completion. The bounded -48 workbench uses Bubble Tea, Bubbles and Lip Gloss; +noninteractive commands retain their existing behavior. Do not add Python, TypeScript, or Rust duplicate CLIs. Keep the package independent of backend and MCP runtime dependencies. ## Delivered and later boundaries @@ -414,8 +422,11 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. journey through contribution export, with negative intake and authority checks. Hosted and human-review journey proof remain separate. Unfinished human review/revision is not current CLI implementation scope. -26. **Optional TUI:** Add a focused public queue/evidence view after its API - workflow is complete. Never require a TUI for agents or scripts. +26. **Optional TUI:** -48 supplies read-only contributor navigation through + existing typed reads. In-TUI findings/Evidence, writes and management remain + separate boundaries; Evidence first needs a shared typed single-page client + port without changing complete CLI exports. Never require a TUI for agents + or scripts. ## Risks and proof @@ -437,6 +448,6 @@ cannot create a private CLI dependency. The CLI is a separate client package, not an ADR 0014 backend adapter. It does not change backend authority or deploy an external provider. Protected first CLI -publication, optional package-manager installers and signing, and a full -contribution journey remain future boundaries; source build and CI are not +publication, optional package-manager installers and signing, broader TUI +journeys and hosted contribution proof remain separate boundaries; source build and CI are not claims of public distribution. diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md new file mode 100644 index 000000000..36bca5ffc --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md @@ -0,0 +1,469 @@ +# WS-CLI-001-48 — First read-only contributor terminal workbench + +- Initiative: `WS-CLI-001` +- Durable disposition: `Planned` +- Intended merge outcome: Add one opt-in contributor workbench to the existing Go binary, reusing public typed reads without changing explicit commands or introducing writes. + +## Intent + +Make project → Ready/Mine/Submissions → exact detail usable without copying +UUIDs. This is one implementation PR, not the whole terminal experience. +The implementation follows this bounded plan. Its verification boundaries below +remain requirements: source implementation and partial local evidence do not +establish four-platform readiness or publication. + +The [screen contract](../../../docs/design/cli-terminal-experience.md) was +originally imported byte-identically from reviewed local source commit +`1a5fe2c14b2c0ad5cfc0e27f6f52a5961fb32c21`, then narrowly reconciled here so its +first-slice scope agrees with this plan. Local source record: +`.commitrail/initiatives/WS-CLI-001/WS-CLI-001-45.md` at that SHA; the commit is +not published, and no GitHub link or publication is implied. This is provenance, +not a second new change bundled into this PR. Independent plan review preceded +implementation; human merge and release publication remain separate actions. + +## Current behavior + +Inspected fetched `origin/main` `7f6a71a69daf563742b72c706e6d09aa742ac25e` +(2026-10-23). There is no TUI. `command.Run` owns Cobra registration, safe +errors, output and environment-based `api.New`. The shared transport preserves +timeouts, bearer forwarding, response validation and failure concealment. +The following public operations already return typed `api.Result[T]`: + +| Surface / explicit CLI | Public `api.Client` method | GET route, below `/api/v1` | Existing owner | +|---|---|---|---| +| Actor binding / `whoami` | `Profile(ctx)` | `/actors/me` | `api/client.go`; backend `api/routes/auth.py` | +| Project picker / `me projects` | `MyProjects(ctx, PageQuery)` | `/actors/me/projects` | `api/contributor_reads.go`; backend `api/routes/contributor_reads.py` | +| Ready / `task ready PROJECT_ID` | `ReadyTasks(ctx, project, limit, cursor)` | `/projects/{project_id}/tasks/ready` | `api/contributor_tasks.go`; backend `modules/tasks/queue_router.py` | +| Mine / `me assignments --project PROJECT_ID --state active` | `MyAssignments(ctx, PageQuery)` | `/actors/me/assignments` | `api/contributor_reads.go`; backend contributor-read routes | +| Attempts / `me submissions --project PROJECT_ID` | `MySubmissions(ctx, PageQuery)` | `/actors/me/submissions` | Same contributor-read owners | +| Task / `task show TASK_ID` | `ContributorTask(ctx, task)` | `/tasks/{task_id}` | `api/contributor_tasks.go`; backend `modules/tasks/router.py` | +| Context / `task context TASK_ID` | `WorkContext(ctx, task)` | `/tasks/{task_id}/work-context` | `api/task_context.go`; backend TASK router | +| Intake / `task requirements TASK_ID` | `SubmissionRequirements(ctx, task)` | `/tasks/{task_id}/submission-requirements` | Same task-context owners | +| Owned attempt / `submission show SUBMISSION_ID` | `Submission(ctx, id)` | `/submissions/{submission_id}` | `api/submission_reads.go`; backend TASK router | + +Go owner paths above are relative to `cli/internal/`; backend paths to +`backend/app/`. `MyProjects` is exact-grant membership, not a global catalogue. +Roles, action hints and prior successful reads are observations, not authority. +`Profile` may perform existing admission/last-seen/audit effects: read-only here +means no explicit product mutation request, not a side-effect-free server. + +Existing proof: `cli/tests/integration/test_contributor_reads_http.py` checks +one-page query/filter/shape behavior; `test_contributor_task_http.py` and +`test_task_context_http.py` protect contributor projections and locked selectors; +`test_submission_http.py` and `test_submission_public_api.py` cover exact owned +attempt reads and denials. `contributor_task_journey.py`, called by +`test_public_self_service.py`, positively reads context/requirements for both +Ready and assigned work. These are not assigned-only endpoints. Guide document +disclosure is assignment-sensitive and remains backend-owned. + +Two deliberately deferred client gaps: + +- `Contributions(ctx, projectScope, filters, writer)` in + `api/contribution_evidence.go` walks up to 100 pages and drops continuation + from its complete output. Evidence therefore displays **CLI handoff only — + not available in this workbench**, with inert argv + `[workstream, contributions, --project, PROJECT_ID]`. No contribution request + is issued by the TUI. Before a later in-TUI Evidence slice, expose one shared + typed page port using existing filter/record/cursor validation; preserve + complete JSON/CSV export, order/identity checks and multipage regressions. +- `SubmissionFindings(ctx, submission, run, limit, cursor)` returns validated + `json.RawMessage`, not a typed result. Detailed findings, preparation/history + browsers and downloads remain CLI handoffs; no TUI JSON decoder is added. + Owned attempt detail still shows typed stored outcome and finding counts. + +## Bounded change + +### Allowed + +The reviewed implementation has this exact allowlist: + +- `cli/go.mod`, `cli/go.sum`: selected dependencies and their resolved sums only. +- `cli/internal/command/command.go`: add one TUI registration; keep `Run`'s + signature and existing execution/error contract. +- New `cli/internal/command/tui.go`, `cli/internal/command/tui_test.go`: + terminal/output guards, client wiring and program lifecycle. +- New `cli/internal/tui/{model.go,requests.go,view.go,text.go,handoff.go}` and + corresponding `{model_test.go,requests_test.go,view_test.go,text_test.go,handoff_test.go}`: + one cohesive contributor model, request/result messages, layout, escaping + and inert argv presentation. No widget/plugin/framework abstraction layer. +- New `cli/tests/integration/{tui_terminal_support.py,test_tui_http.py,test_tui_public_api.py}`: + bounded POSIX PTY/Windows ConPTY helper, hostile socket peer and real + public-API journey. +- New `cli/tests/integration/tui_native_proof.py` and + `cli/tests/integration/test_tui_native_proof.py`: narrowly scoped pytest + execution receipt and fail-closed verifier with discriminating self-tests. +- New `cli/tests/integration/requirements-native.txt`: exact, hash-verified + Python dependency closure for the native socket/terminal proof only. +- `cli/tests/integration/http2_fixture.py`: move its cryptography imports into + the existing `goaway_fixture` only; retain the real TLS/HTTP2 regression and + its dependency when invoked. No fixture behavior or assertions change. +- `.github/workflows/cli-release.yml`: add the same HTTP/terminal suite to + each existing native proof job, with bounded result validation and a separate + exact-source test artifact. Preserve the four targets, existing checks, + permissions, timeouts, publication guards and archive asset allowlist. +- `.github/workflows/cli.yml`, `scripts/test_lightweight_agent_gates.py`: + separate the two new TUI socket/proof test files into one explicit isolated + group after the existing task HTTP group. Preserve the exact collected union, + disjoint selection, all existing public-API groups, 300-second group limits, + 15-minute job limit and failure/timeout propagation. No retries, skips, + deselection, changed test assertions or increased budgets are authorized. + Move the unchanged `test_task_guide_http.py` module from the broad group into + the existing guide HTTP group; preserve six isolated runner invocations. + Partition those six groups across a closed two-value `http`/`api` job matrix, + with fail-fast disabled and the unchanged 15-minute limit per job. Both + partitions retain exact-source build, lint, PostgreSQL and provider custody; + the reusable workflow aggregate must require both partitions to succeed. +- `cli/README.md`, `docs/engineering/cli-release.md`, + `docs/design/cli-terminal-experience.md`, this record, + `.commitrail/initiatives/WS-CLI-001/OVERVIEW.md`, `.commitrail/INDEX.md`, + `docs/roadmap_status.md`: reconcile only this intended merged boundary. +- If local roadmap exports exist at implementation time, update ignored + `sheets/workstream_roadmap.xlsx` and `.csv` together; never commit them. + +Brace lists above enumerate exact new files, not wildcard permission. If a +necessary additional path/transport change emerges, amend scope and obtain +affected review before using it; do not silently enlarge this PR. + +### Not allowed + +No backend/API/schema/authorization changes; no `cli/internal/api/` edits, +parallel client, stdout parsing, shell execution, raw-HTTP console, subprocess +CLI invocation, automatic clipboard/open-URL behavior or local policy engine. +No writes, claim/start controls, prepare/submit, downloads, export, polling, +token acquisition/renewal, keychain/login, cached credentials, recovery journal, +local recent-project file or other application persistence. No management/admin +workspace, public project catalogue, dependency on the separate #665 catalogue, publication, +release settings/publication changes, human Review or fulfillment controls. +Do not broaden build/platform scope, weaken CI or remove existing tests. + +## Design and decisions + +### Bounded CLI proof partition + +The new terminal and proof-runner tests add 63 cases to the existing broad +socket group, which reached its 300-second limit in hosted verification. +Collection inspection establishes 339 cases partitioned into 276 existing +cases and the exact two-file 63-case TUI group, with no overlap or omission. +Keep both groups in the required HTTP partition, each using the canonical +isolated runner and a distinct cleanup metadata file. This does not depend on +another workflow's result or remove native per-platform proof. The CI-integrity +review requires exact argv assertions and simulated success/failure/timeout +propagation for all six groups. Hosted execution must still prove the unchanged +overall job budget; collection equality alone is not runtime evidence. + +The first partition still exhausted the broad group's budget at the same +252-node boundary as the original run, immediately before the task-guide +context-deadline case. All three intentional deadline probes passed separately +in 40.99 seconds, discriminating cumulative cost from a hung test. Move all 25 +unchanged task-guide cases to the existing guide HTTP group: broad 276 → 251, +guide 175 → 200, with exactly those 25 identities moved and no lost or duplicate +cases. Keep six groups, their original timeout limits and isolation semantics. + +A full hosted run passed in 12m42s, but its unchanged-source ready-state run +exhausted the 15-minute job limit during the final batch group after all earlier +groups passed. The cumulative single-job budget is therefore not robust to +observed runner variation. Use one fixed two-value matrix: `http` runs broad +HTTP, terminal/proof and guide HTTP; `api` runs public API, self-service and +batch journeys. Each partition independently builds the exact checkout and +uses the existing PostgreSQL/MinIO setup and cleanup. Do not introduce binary +handoff artifacts, new fixtures, retries, or larger per-test/per-job limits. +Disable matrix fail-fast so one failure does not cancel the other partition's +evidence. Existing backend full-suite and final checks require the reusable +workflow's aggregate success. CI review must protect the closed partition, +exactly one assignment of every existing group, and failure propagation; +removing either partition or a required condition must fail a focused gate. + +### One contributor workspace + +`workstream tui [--plain]` is explicitly opt-in. Require interactive stdin +and stdout before any API call or terminal mode change; never reopen `/dev/tty` +to bypass redirection. Reject `--output json` with the existing structured +argument-error convention (exit 2), not an alternate JSON dashboard. Missing +environment/invalid origin use existing client validation. Existing commands +never enter the TUI; the only expected old help difference is the new root +`tui` entry. No new global flags or output changes. + +After actor binding, fetch one project page. Selection opens Ready; Tab cycles +Ready/Mine/Submissions, fetching only the selected view. Fixed limit 50, Mine +uses `ProjectID` plus `State=active`, Submissions uses `ProjectID` only. +`n` replaces the page using its untouched continuation; no concatenated feed, +automatic next-page request, cursor interpretation or backward-page cache. +`r` resets to page one and clears selection/detail; project/tab change resets +that scope. `/` filters fetched titles/names only and is labelled local; it +does not alter server filters or cursor scope. Page length is not a total. + +Enter on Ready issues only fresh `ContributorTask`. Show exact ID, instructions, +criteria and both locked accepted-submission/completed-review terms through +existing `api.CompensationAwards`; preserve decimal strings and explicit unpaid. +No context/intake tab or guide request from Ready in this slice. That is a UI +scope cut, not a new backend eligibility rule. From active Mine, Enter first +reads the same exact task; explicit Context/Intake sections invoke their typed +reads independently. Display returned guide document labels, media type, size +and hash without download links or a bytes-verified claim. No eager prefetch. +Submissions Enter freshly reads its selected ID and verifies its returned task +matches the selected own-work row; the response has no project field, so retain +the row's selected-project context without inventing one. Never substitute a +manager projection when contributor reads fail. Task/detail/context/requirements +must also agree with the selected project and exact task; compare returned +guide/version/policy selectors where both sections supply them, never substitute +a current guide for a locked one or present mismatched sections together. + +Keep keys minimal: arrows select/scroll, Enter inspect, Tab change section, +Esc back/cancel, `g` project picker, `n` next, `r` refresh, `/` local filter, +`c` inert CLI argv, `?` help, `q`/Ctrl+C quit. No `a`, `y` or mutation form. +CLI handoff shows tokens as separate labelled arguments, never a universally +shell-quoted command. In particular `[workstream, submission, findings, ID]` +is labelled an external manual read; display-only Evidence does not look like +a populated or loading Evidence tab. Handoff never executes or copies anything. + +### Request and privacy boundaries + +Use a small TUI-owned read-only interface whose method signatures are exactly +the nine existing methods above; `*api.Client` satisfies it directly. No +adapter, transport copy, response reinterpretation or exported generic service +locator. Bubble Tea commands run reads asynchronously under cancellable +contexts; the model never blocks waiting for network I/O. Tag each result with +actor/session and monotonically increasing view-request generation plus exact +project/tab/resource/page selectors. A result applies only to the still-current +request. Project switch, refresh, back navigation, quitting and newer requests +cancel and invalidate older results; cancellation alone is not the stale guard. + +On fresh 401/403/404, clear affected rows, detail, filters/continuation and +pending results before displaying denial/concealment. A session-wide auth +failure clears all contributor data and permits quit/help only; no automatic +token retry. A resource-specific denial clears the affected project/view and +returns to an uncached picker on explicit `g`. Treat timeout/5xx as unavailable, +not empty/success: clear detail, show bounded failure and explicit refresh. +No retained content may reappear through a late success after denial. Do not +print raw errors, bodies, headers, token, origin credentials or debug traces; +use safe `api.Failure` code/status/correlation fields only. Quit cancels requests +and restores terminal modes before bounded stderr/exit handling, including a +program error. SIGKILL cannot guarantee terminal restoration; do not claim it. + +All server/environment strings cross one TUI text boundary before layout: +escape C0/C1, ESC/OSC/CSI, bidi/format controls and Unicode line separators; +allow wrapping only from application-owned newline/layout logic. Do not parse +Markdown, ANSI, URLs or paths. Escaping precedes width/cell measurement. Existing +`command.safeText` remains unchanged because its command-output owner differs; +the focused TUI helper follows that established rule, not a repository-wide +sanitizer refactor. Bound lists to one fetched page and text to existing client +response limits; viewport rendering must not allocate arbitrary cell grids. + +### Selected terminal modules + +Select stable versions (research 2026-10-23), not `latest` or pre-releases: + +| Direct module | Exact version | Use / primary source | +|---|---|---| +| `charm.land/bubbletea/v2` | `v2.0.10` | Model/update/view and terminal lifecycle; [tag module](https://github.com/charmbracelet/bubbletea/blob/v2.0.10/go.mod), [options](https://github.com/charmbracelet/bubbletea/blob/v2.0.10/options.go) | +| `charm.land/bubbles/v2` | `v2.2.1` | Existing viewport/key/help primitives only; [tag module](https://github.com/charmbracelet/bubbles/blob/v2.2.1/go.mod) | +| `charm.land/lipgloss/v2` | `v2.0.6` | Measured layout/styling; [tag module](https://github.com/charmbracelet/lipgloss/blob/v2.0.6/go.mod) | +| `github.com/charmbracelet/x/term` | `v0.2.2` | Descriptor terminal guard, shared upstream dependency; [tag source](https://raw.githubusercontent.com/charmbracelet/x/term/v0.2.2/term/term.go) | + +Tag manifests declare Go floors 1.26/1.25, below repository Go 1.27.1; Bubbles +requires Tea 2.0.8/Lip Gloss 2.0.5, below selected pins. This is source-based +compatibility inspection, not a completed module resolution/build. Run actual +`go mod tidy`, verification and native builds before claiming compatibility. +Do not import clipboard, textarea, file picker or mouse functionality. MVS +transitives/sums must be reviewed; do not force lower transitive versions to +make the pins appear compatible. A genuine conflict returns to plan review. + +120×40 uses list/detail panels; 80×24 uses sequential views with the same IDs. +Below 70×20 use one scrolling plain list/detail; below 40×10 show a bounded +resize/quit notice without data requests. `--plain` disables alternate screen, +decorative panels/color and uses ASCII markers with the same navigation and +read ownership. `NO_COLOR` with a nonempty value disables application colors; +it does not falsely promise zero cursor-control bytes in an interactive TUI. +All statuses and selection have text/ASCII labels, never color-only meaning. +No truecolor, mouse, clipboard, Unicode glyph, Unix-only key or OS shell is +required. Plain mode is a screen-reader-oriented fallback, not an untested +accessibility certification; explicit text/JSON CLI remains the noninteractive +alternative. + +## Acceptance criteria + +- [ ] Only `tui` changes terminal modes; rejected non-TTY/JSON/config input + makes zero network requests and leaves terminal settings untouched. +- [ ] One bound actor can navigate one-page membership, Ready, active Mine and + own Submissions, then fresh exact details, with the typed public client. +- [ ] Ready never requests assignment-sensitive guide bytes or context/intake + in this UI; Mine context/requirements retain exact locked lineage and fresh + denial. No local role check grants or denies a backend read. +- [ ] Explicit next/refresh/filter semantics preserve scope and opaque cursors; + no background calls, whole-export traversal or implicit product write occurs. +- [ ] Late responses cannot replace current scope, resurrect denied data or + redraw after quit. Canonical denials never become empty queues. +- [ ] Hostile text cannot execute terminal controls, leak tokens, create URLs + or shell commands, or overflow the chosen viewport. Stored outcomes and exact + compensation terms remain observations, not acceptance/fulfillment predictions. +- [ ] Wide/narrow/plain/no-color keyboard operation and teardown pass built + terminal tests; Evidence/findings are honestly marked manual CLI handoffs. +- [ ] Existing explicit CLI output/exit/flags/subcommand help, complete export + behavior and tests stay intact; root help gains only the new command. + +## Risk and review routing + +- Risk class: `L1`: bounded architecture/dependency and untrusted terminal/data + disclosure boundary; no authority or policy ownership change. +- Work type: architecture/client UI/dependencies/tests/docs. No urgency supplied. +- Required reviewers: `architecture` (one client/read-port boundary and scope), + `security` (terminal injection, bearer/cache/denial handling), `qa` (real PTY, + stale-response falsification and public-API guards), `documentation` (truthful + exposure/remaining gates), `reuse_dedup` (client validators/widgets), + `test_delta` (retained explicit-command/export proof). `product_ops` reviews + outcome/terms/hand-off truth. Related tracks may share one explicitly labelled + assignment; do not claim separate independent verdicts. `ci_integrity` + additionally reviews the narrowly allowed native proof workflow change: + full selection, failure propagation, cancellation and artifact custody. +- Human review focus: accept the deliberate Evidence/findings handoffs and + active-only Mine cut; inspect real native terminal proof on all four existing + supported targets before readiness. No new + choice is needed to write code once the plan passes; unavailable proof is + reported rather than silently waived or replaced by cross-compilation. +- Budget posture: one model and three small lists; no generic UI framework, + management fanout or speculative client refactor. Lead freezes a clean + candidate, runs shared checks once and routes bounded independent review. + +## Evidence + +Plan evidence is owner/guard inspection and primary-source dependency research, +not runtime proof. The following are required **future implementation tests**: + +| Proof | Concrete test boundary and failure it must detect | +|---|---| +| Model/query tests | Named Go tests in `model_test.go`/`requests_test.go`: delayed project A success after switch to B; delayed success after A denial; duplicate `n` while pending; refresh clears cursor; no request on local filter, resize or handoff; exact project/row/task substitutions refused. Return a late successful result even after cancelling its context so the generation assertion, not cooperative cancellation, is exercised. | +| Text/layout tests | `text_test.go`/`view_test.go`: hostile task/project/summary/failure text with C0/C1/OSC52/OSC8/CSI/bidi and long Unicode; 120×40, 80×24, 60×18 and 30×8; same selected ID through resize, bounded cell widths/scroll and readable ASCII/no-color status. Snapshots supplement behavior assertions, not replace them. | +| Built socket/terminal | `test_tui_http.py`: real executable on POSIX PTY or Windows ConPTY, valid client schemas, request log rejects all non-GET/unlisted routes; one cursor page per `n`; Ready issues only detail; denied detail removes old text; HTTP delayed-response race; normal quit, Ctrl+C, pending quit and signal teardown restore observed terminal modes/cursor/alternate-screen state. Esc navigates without exiting. Signal teardown is not a program error. Handoff argv is checked against existing command `--help`, never invoked automatically. Redirected stdin/stdout and JSON guard assert exit 2, zero API requests and no terminal escapes; existing ordinary command non-TTY tests remain. | +| Program-error cleanup | Named tests in `cli/internal/command/tui_test.go` inject program-run/output failure using test-only seams, proving cleanup occurs before bounded error propagation. Cancellation before startup alone is insufficient. No production fault-injection switch or claim that a signal proves an output failure. | +| Real public-API journey | `test_tui_public_api.py`: built PTY process → loopback FastAPI with actual Flow verifier → isolated PostgreSQL. Reuse signed-actor/grant helpers and approved-policy/started-assignment fixtures; no AUTH/TASK read-port override. Arrange a Ready task and active Mine plus one owned immutable attempt through existing public setup or canonical owner fixtures, never by weakening guards. Compare direct same-token GETs to selected IDs/terms/detail before driving keys. Revoke the exact Submitter grant through its real administration owner, explicitly refresh, and prove denial/cache purge; foreign actor/task is the denied control. Record methods/paths and unchanged product row counts (existing admission/read-audit effects excluded). | +| Fixture feasibility | `contributor_task_journey.py` already proves qualified Submitter A can read Ready detail/context/requirements and cannot read foreign/draft work; `test_submission_public_api.py` proves canonical preparation/Submission lineage and owned detail. New signed-identity setup must first pass the real same-token positive GETs; a denial-only fixture is insufficient. `_client` from the intake test overrides actor context, so it must NOT be reused as this new test's verifier. Reuse the signed verifier pattern from `test_contribution_evidence_public_api.py`, binding the fixture's actual identity link; no fake grants. | +| Native terminal proof | Each existing native release-proof job builds its native executable and runs the same `test_tui_http.py` suite: POSIX PTYs on Linux/macOS and real ConPTY on Windows. Retain all existing Go/build/install checks. Keyboard/help, resize, plain/NO_COLOR, q/Ctrl+C/error restoration and denial must execute without skips on all four targets. Validate nonzero collected/executed tests and zero skipped/deselected/xfail cases; propagate failures and cancellation into native proof and assembly. Upload separate source-SHA/platform-bound test evidence, not extra release assets. Cross-build/install smoke is not interactive proof. | +| Regression/full suites | `cd cli`: `go mod verify`, `go mod tidy -diff`, `go vet ./...`, `go test ./...`, `go test -race ./internal/tui ./internal/command`, `go build -trimpath ./cmd/workstream`; retain complete contribution JSON/CSV multipage/native regression. Run existing full CLI HTTP/native API suites through `.github/workflows/cli.yml` and full required backend/Agent Gates/integration lanes. New `test_tui_public_api.py` is selected by the existing native `*_public_api.py` glob; no selection bypass. | +| Docs/process | `python3 scripts/check_markdown_links.py`; `python3 scripts/check_stale_workstream_wording.py`; after commit, repository tooling Python `scripts/check_commitrail_records.py --base-ref origin/main`; diff whitespace/scope checks. If exports exist, verify one XLSX sheet named `WorkStream RoadMap` and current Workstream definition in README and both exports. | + +## Review findings + +### Native proof refinement + +Inspection of the existing four-runner release workflow found Go tests and +installation proof, but no interactive terminal tests. The current host cannot +provide Windows/macOS interactive custody. Use those existing native runners +instead of leaving an unavailable manual proof as an implicit readiness waiver. +This changes test execution only, not the supported platforms or release channel. +The workflow creates a fresh isolated virtual environment and installs the +complete exact dependency closure using `pip --isolated` with +`--require-hashes --only-binary=:all:` from `requirements-native.txt`. +The direct dependency is `pytest==9.1.1`. Reused HTTP fixtures transitively +import the separate HTTP2 fixture, but the TUI tests do not invoke its TLS +certificate generator. Load cryptography inside that existing generator, not +at module import. This avoids introducing an unused native crypto dependency +or downgrading it merely because 50.0.0 lacks an Intel Mac wheel. Existing HTTP2 +tests still use the backend's cryptography dependency and must pass unchanged. +Prove the TUI module collects and executes in the isolated pytest-only closure; +prove the GOAWAY test still executes real TLS/HTTP2 with its existing dependency. +Inspect actual pytest wheel metadata for all four targets and lock every +transitive dependency, including Windows-only dependencies. The backend lock +provides version provenance, not runtime +installation authority: its Linux-only resolution cannot stand in for native +Windows/macOS dependency proof. Obtain wheel hashes from the package index and +verify installation on each target; missing wheels or hashes fail, never fall +back to an unpinned install. No backend runtime, real credentials or new service +is needed for this socket-only suite. The committed native requirements file is +the authoritative input, covered by the existing CLI workflow path selection. + +`TUI-CI-001` requires more than JUnit totals. A narrowly scoped pytest runner +records the complete raw node-ID multiset during collection, deselection events, +and every setup/call/teardown report, including `wasxfail`. Clear inherited +selection options and disable automatic third-party plugin loading; load only +the explicit proof plugin and select the exact HTTP test file. Require nonzero +collection, unique node IDs, no deselection, and exactly one successful +setup/call/teardown sequence per collected test. Reject skips, xfail, XPASS, +collection errors, missing/duplicate reports and any nonzero pytest exit. +Cancellation cannot produce a valid completion receipt. Do not infer absence of +deselection or XPASS from standard JUnit. Self-tests must discriminate omitted +tests, duplicates, empty collection, skips, xfail, non-strict XPASS, ordinary +failure and interrupted execution against a valid passing control. + +Bind the validated receipt to the actual checked-out source SHA, native target, +workflow run ID and attempt. Keep it outside release-native/assembled assets and +upload it under a separate test-evidence artifact name. The test runner and +receipt verifier both propagate failure to the existing native job and assembly +gate. Retain existing permissions, timeouts and publication behavior. Update +the canonical release runbook to distinguish these interactive tests from +installation smoke checks. `TUI-CI-002` is addressed by the isolated hashed +closure above; direct top-level pins alone are not sufficient. + +Terminal error proof is split explicitly: `test_tui_http.py` owns built-process +q/Ctrl+C/pending-quit and signal teardown with observed terminal modes, cursor +and alternate-screen restoration. Signal teardown is not a program-error test. +`cli/internal/command/tui_test.go` injects program-run/output-writer failures +through test-only seams, asserting cleanup before bounded error handling and +observable failure. The proof-table reference to forced program-error paths +uses this Go boundary, not a production fault-injection option. No runtime test +backdoor is introduced. All four native jobs run both proof boundaries. + +On Darwin, the terminal observer performs one zero-time read-readiness check +before comparing the complete terminal state. XNU sets transient `PENDIN` when +restoring canonical mode; its readiness path settles pending input processing +without a user-space read. No terminal flags are masked or written by this +observer. A private-PTY regression deliberately injects actual raw mode after +normal CLI teardown and requires the observer to reject it without repair. +Both native macOS architectures must prove this behavior; Linux results alone +do not establish Darwin restoration. + +The shared helper owns bounded platform-specific terminal plumbing, not product +behavior. Windows uses typed `ctypes` ConPTY APIs and a helper process inside the +pseudoconsole to inspect its actual input/output console modes before and after +the CLI. Querying host anonymous pipes does not prove console-mode restoration. +Send keyboard input through the terminal; never broadcast Ctrl+C to unrelated +processes. Drain output with bounded buffers, time out stalled children, and +close the pseudoconsole, pipe/attribute/process handles and owned child process +tree on every exit. A private temporary mode/exit receipt contains no bearer or +server text. Unsupported Windows plumbing is a failed proof, never a skip. +The bearer remains in the test environment, not process arguments. Use the +same hostile HTTP cases on each platform rather than a duplicate native-only +happy-path suite. Amendments require focused plan/CI review before implementation; +actual Windows execution remains required to validate the ABI and cleanup. + +The prior design's contribution-pagination finding is addressed by a smaller +first slice: Evidence is an inert CLI handoff, not an unimplemented page port +or exporter-backed TUI list. Detailed checker findings are also deferred at +their current raw-result boundary. `TUI-PLAN-001` identified contradictory +first-slice wording in the imported design; its first-slice section now names +the same nine typed reads and manual findings/Evidence handoffs, and limits the +typed contribution-page prerequisite to a later Evidence slice. The broader +design and later mutation gates are unchanged. `TUI-PLAN-002` identified a +GitHub URL to unpublished local provenance; it is replaced by the labelled +local source SHA and path without publishing anything. Independent plan review +passed on the reconciled plan. The native-proof amendment separately resolved +`TUI-CI-001` (execution completeness), `TUI-CI-002` (hashed native dependency +closure), and `TUI-PROOF-003` (distinct terminal and program-error evidence). +These are plan reviews, not substitutes for implementation review or execution. + +## Reconciliation + +- Current-source reconciliation: integrated main is `2e6cee003`, including + the separate system-reader catalogue and release guard. Publication is not + a dependency. Recheck owners after integrating then- + current main; preserve the reviewed design's separate later mutation gates. +- Roadmap assessment: the implementation updates the affected CLI summary, + scoreboard, trace, dependency diagram and remaining gates for its intended + merged read-only boundary. Broader TUI design remains proposed. No local + spreadsheet exports were present in this implementation worktree. +- Intended implementation merged outcome must update the CLI executive summary, + external-interface scoreboard, CLI detail/trace, dependency diagram and + remaining gates in `docs/roadmap_status.md`, plus CLI README, overview and + INDEX next boundary together. Say **local opt-in read-only contributor TUI**, + not completed TUI, deployed capability or published binary. Preserve true + human-review handoff and false-policy authorized acceptance branches. Name + Evidence/findings handoffs, all mutations/management and public distribution + as remaining, not delivered; update local XLSX/CSV together only if present. +- Next usable boundary: complete native proof and implementation review for + this bounded slice; do not start another chunk automatically. Later Evidence first + needs the narrow shared typed page port; writes still require reviewed + operation-specific private recovery receipts, not a generic journal. +- Module resolution, Go checks and Linux terminal execution have passed locally. + The signed public-API fixture has demonstrated all nine positive reads and + real grant revocation, but needs a final combined-head run. Native Windows + and macOS terminal execution remains outstanding; accessibility certification + is not claimed. No retained-data cleanup. diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index b76f13067..8c83e7978 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -144,6 +144,43 @@ jobs: go vet ./... go test ./... python ../scripts/test_cli_release_smoke.py + - name: Isolate hash-verified native terminal dependencies + shell: bash + run: | + set -euo pipefail + python -m venv "$RUNNER_TEMP/tui-proof-venv" + proof_python="$RUNNER_TEMP/tui-proof-venv/bin/python" + if [ "$RUNNER_OS" = Windows ]; then + proof_python="$RUNNER_TEMP/tui-proof-venv/Scripts/python.exe" + fi + "$proof_python" -m pip --isolated install --require-hashes --only-binary=:all: --no-cache-dir -r cli/tests/integration/requirements-native.txt + printf 'TUI_PROOF_PYTHON=%s\n' "$proof_python" >> "$GITHUB_ENV" + - name: Reconcile exact native terminal execution + shell: bash + env: + COMMIT: ${{ needs.prepare.outputs.commit }} + PLATFORM: ${{ matrix.platform }} + PYTEST_DISABLE_PLUGIN_AUTOLOAD: '1' + PYTEST_ADDOPTS: '' + PYTEST_PLUGINS: '' + run: | + set -euo pipefail + null_config="$("$TUI_PROOF_PYTHON" -c 'import os; print(os.devnull)')" + "$TUI_PROOF_PYTHON" -m pytest -c "$null_config" -p no:cacheprovider -o addopts= -q cli/tests/integration/test_tui_native_proof.py + export WORKSTREAM_CLI_EXECUTABLE="$RUNNER_TEMP/workstream-tui-proof" + if [ "$RUNNER_OS" = Windows ]; then + export WORKSTREAM_CLI_EXECUTABLE="$WORKSTREAM_CLI_EXECUTABLE.exe" + fi + (cd cli && go build -trimpath -o "$WORKSTREAM_CLI_EXECUTABLE" ./cmd/workstream) + "$TUI_PROOF_PYTHON" cli/tests/integration/tui_native_proof.py run --output "$RUNNER_TEMP/tui-native-evidence" --source "$COMMIT" --platform "$PLATFORM" --run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" + "$TUI_PROOF_PYTHON" cli/tests/integration/tui_native_proof.py verify --output "$RUNNER_TEMP/tui-native-evidence" --source "$COMMIT" --platform "$PLATFORM" --run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" + - name: Upload separate native terminal test evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cli-tui-evidence-${{ needs.prepare.outputs.commit }}-${{ matrix.platform }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/tui-native-evidence/ + if-no-files-found: error + retention-days: 7 - name: Build deterministic archive twice and smoke native installation shell: bash env: @@ -176,6 +213,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: ${{ github.sha }} + fetch-depth: 0 persist-credentials: false - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: @@ -193,6 +231,22 @@ jobs: with: name: cli-native-windows-amd64-attempt-${{ github.run_attempt }} path: native/windows-amd64 + - name: Download separate native terminal evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: cli-tui-evidence-${{ needs.prepare.outputs.commit }}-*-run-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: native-tui-evidence + - name: Revalidate all four native terminal completion receipts + shell: bash + env: + COMMIT: ${{ needs.prepare.outputs.commit }} + run: | + set -euo pipefail + git show "$COMMIT:cli/tests/integration/tui_native_proof.py" > "$RUNNER_TEMP/tui_native_proof.py" + for platform in linux-amd64 darwin-amd64 darwin-arm64 windows-amd64; do + evidence="native-tui-evidence/cli-tui-evidence-$COMMIT-$platform-run-$GITHUB_RUN_ID-attempt-$GITHUB_RUN_ATTEMPT" + python "$RUNNER_TEMP/tui_native_proof.py" verify --output "$evidence" --source "$COMMIT" --platform "$platform" --run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" + done - name: Bind source and assemble exact allowlist shell: bash env: diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 271cf5e2a..ddf0a3e73 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -17,6 +17,10 @@ jobs: cli-public-contract: runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + group: [http, api] services: postgres: image: docker.io/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 @@ -118,6 +122,7 @@ jobs: exit 1 - name: Exercise built CLI against task HTTP and local file boundaries + if: ${{ matrix.group == 'http' }} working-directory: backend env: WORKSTREAM_CLI_EXECUTABLE: ${{ runner.temp }}/workstream-cli @@ -131,9 +136,29 @@ jobs: -- uv run --frozen python -m pytest -q ../cli/tests/integration \ '--ignore-glob=../cli/tests/integration/*_public_api.py' \ '--ignore-glob=../cli/tests/integration/test_guide*.py' \ - --ignore=../cli/tests/integration/test_public_self_service.py + --ignore=../cli/tests/integration/test_public_self_service.py \ + --ignore=../cli/tests/integration/test_task_guide_http.py \ + --ignore=../cli/tests/integration/test_tui_http.py \ + --ignore=../cli/tests/integration/test_tui_native_proof.py + + - name: Exercise native terminal HTTP and proof contracts + if: ${{ matrix.group == 'http' }} + working-directory: backend + env: + WORKSTREAM_CLI_EXECUTABLE: ${{ runner.temp }}/workstream-cli + WORKSTREAM_TEST_ADMIN_DATABASE_URL: postgresql+asyncpg://workstream:workstream@localhost:5433/postgres + shell: bash + run: | + set -euo pipefail + uv run --frozen python scripts/run_isolated_tests.py \ + --metadata-json "${RUNNER_TEMP}/workstream-cli-tui-isolation.json" \ + --timeout-seconds 300 \ + -- uv run --frozen python -m pytest -q \ + ../cli/tests/integration/test_tui_http.py \ + ../cli/tests/integration/test_tui_native_proof.py - name: Exercise existing guide HTTP contracts + if: ${{ matrix.group == 'http' }} working-directory: backend env: WORKSTREAM_CLI_EXECUTABLE: ${{ runner.temp }}/workstream-cli @@ -143,6 +168,7 @@ jobs: set -euo pipefail shopt -s nullglob guide_files=() + guide_files+=(../cli/tests/integration/test_task_guide_http.py) for test_path in ../cli/tests/integration/test_guide*.py; do if [[ "${test_path}" != *_public_api.py ]]; then guide_files+=("${test_path}") @@ -155,6 +181,7 @@ jobs: -- uv run --frozen python -m pytest -q "${guide_files[@]}" - name: Exercise existing native guide API callers + if: ${{ matrix.group == 'api' }} working-directory: backend env: WORKSTREAM_CLI_EXECUTABLE: ${{ runner.temp }}/workstream-cli @@ -176,6 +203,7 @@ jobs: -- uv run --frozen python -m pytest -q "${native_files[@]}" - name: Exercise existing native CLI self-service caller + if: ${{ matrix.group == 'api' }} working-directory: backend env: WORKSTREAM_CLI_EXECUTABLE: ${{ runner.temp }}/workstream-cli @@ -187,6 +215,7 @@ jobs: -- uv run --frozen python -m pytest -q ../cli/tests/integration/test_public_self_service.py - name: Exercise complete native CLI batch journey + if: ${{ matrix.group == 'api' }} working-directory: backend env: WORKSTREAM_CLI_EXECUTABLE: ${{ runner.temp }}/workstream-cli diff --git a/cli/README.md b/cli/README.md index c8bf0d22e..386ff0c37 100644 --- a/cli/README.md +++ b/cli/README.md @@ -14,6 +14,51 @@ contribution-evidence exports and exact Audit Authority task reads: Separate commands record human guide sufficiency diagnostics and warning acknowledgements; neither approves policy nor activates a guide. +## Interactive contributor workspace + +Run `workstream tui` in an interactive terminal using the same +`WORKSTREAM_API_URL` and `WORKSTREAM_TOKEN` environment as explicit commands. +`workstream tui --plain` uses ASCII, no application colors and no alternate +screen; a nonempty `NO_COLOR` also disables application colors. Interactive +cursor controls remain necessary. For redirected input/output or automation, +use the explicit commands below with text or `--output json`; the TUI refuses +non-terminal input/output and JSON mode before making an API request. + +Start with your exact-grant projects, then browse **Ready**, **Mine** (active +assignments) and **Submissions**. Enter opens fresh contributor-safe task or +owned Submission detail. Active Mine details also offer explicit Context and +Intake reads. Each list fetches one page; filtering searches only that loaded +page. Project membership, terms and action hints are observations, not authority +for a later operation. Workstream rechecks each public read. + +| Key | Action | +|---|---| +| Arrows / Enter | Select, scroll and inspect | +| Tab / Esc | Change section / go back or cancel | +| `g` | Fetch the project picker again | +| `n` / `r` | Fetch the next page / refresh the current view | +| `/` | Filter loaded names/titles locally; Enter finishes and Esc cancels | +| `c` / `?` | Show inert CLI arguments / keyboard help | +| `q` / Ctrl+C | Quit and cancel outstanding client reads | + +This first workspace is read-only: it does not claim/start work, prepare or +submit ZIPs, download documents, export records, poll, acquire tokens or save +local history. Guide metadata is not proof of downloaded-byte integrity. +Detailed findings and contribution evidence are clearly labelled manual CLI +handoffs, not populated TUI screens; displayed arguments never execute or copy +automatically. Use `submission findings SUBMISSION_ID` and +`contributions --project PROJECT_ID` explicitly when needed. + +A denial clears affected content; expired authentication clears the session's +work data. Network failure is shown as unavailable, never as an empty queue or +successful operation. Refresh is deliberate. No background request retries or +write recovery occur. The profile read may perform Workstream's existing actor +admission, last-seen and audit effects; “read-only” does not mean no server-side +effects. This local interface does not establish a deployed service, payment, +or completion of the broader [terminal design](../docs/design/cli-terminal-experience.md). + +## Explicit commands + | Command | Public API | |---|---| | `workstream whoami` | `GET /api/v1/actors/me` | diff --git a/cli/go.mod b/cli/go.mod index 28746ef4a..ae75b7d41 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -2,9 +2,29 @@ module github.com/Flow-Research/workstream/cli go 1.27.1 -require github.com/spf13/cobra v1.10.2 +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.10 + charm.land/lipgloss/v2 v2.0.6 + github.com/charmbracelet/x/term v0.2.2 + github.com/spf13/cobra v1.10.2 +) require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index a6ee3e0fb..ee33e56be 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,10 +1,52 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= +charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cli/internal/command/command.go b/cli/internal/command/command.go index 3c4295e60..7d771c9ed 100644 --- a/cli/internal/command/command.go +++ b/cli/internal/command/command.go @@ -178,6 +178,7 @@ func Run(args []string, stdout, stderr io.Writer, getenv environment, version st root.AddCommand(operations) addContributorTasks(root, client, &output, stdout) addContributorReads(root, client, &output, stdout) + addTUI(root, client, &output, stdout, getenv) err := root.ExecuteContext(context.Background()) if err == nil { diff --git a/cli/internal/command/tui.go b/cli/internal/command/tui.go new file mode 100644 index 000000000..725acf01e --- /dev/null +++ b/cli/internal/command/tui.go @@ -0,0 +1,129 @@ +package command + +import ( + "context" + "io" + "os" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" + "github.com/Flow-Research/workstream/cli/internal/tui" + "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" +) + +func addTUI(root *cobra.Command, client func() (*api.Client, error), output *string, stdout io.Writer, getenv environment) { + var plain bool + cmd := &cobra.Command{Use: "tui", Short: "Open the opt-in read-only contributor workbench", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if *output != "text" { + return commandError{"invalid_output", "tui requires text output; use ordinary commands with --output json"} + } + input, ok := cmd.InOrStdin().(*os.File) + out, outputFile := stdout.(*os.File) + if !ok || !outputFile || !term.IsTerminal(input.Fd()) || !term.IsTerminal(out.Fd()) { + return commandError{"interactive_terminal_required", "tui requires interactive stdin and stdout; use workstream --help and ordinary commands with --output json"} + } + apiClient, err := client() + if err != nil { + return err + } + return runTUI(cmd.Context(), apiClient, input, stdout, getenv, plain) + }} + cmd.Flags().BoolVar(&plain, "plain", false, "Use a plain single-column view without alternate screen or color") + root.AddCommand(cmd) +} + +func runTUI(ctx context.Context, client tui.ReadClient, input io.Reader, output io.Writer, getenv environment, plain bool) (err error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + observed := &terminalOutput{destination: output, cancel: cancel} + var terminalWriter io.Writer = observed + if file, ok := output.(term.File); ok { + terminalWriter = terminalFileOutput{terminalOutput: observed, File: file} + } + noColor := plain || getenv("NO_COLOR") != "" + model := tui.New(ctx, client, getenv("WORKSTREAM_TOKEN"), plain, noColor) + defer model.Close() + // Only terminal capabilities reach the renderer, never the bearer environment. + env := []string{} + for _, name := range []string{"TERM", "COLORTERM", "TERM_PROGRAM"} { + value := getenv(name) + if len(value) > 64 || strings.IndexFunc(value, func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_.+-", r)) + }) >= 0 { + continue + } + if value != "" { + env = append(env, name+"="+value) + } + } + if noColor { + env = append(env, "NO_COLOR=1") + } + program := tea.NewProgram(model, tea.WithContext(ctx), tea.WithInput(input), tea.WithOutput(terminalWriter), tea.WithEnvironment(env), tea.WithoutCatchPanics()) + err = runTerminalProgram(program) + // Bubble Tea discards renderer flush errors, including errors during close. + // A broken output must still produce an observable failure, not exit zero. + if observed.failed() { + return &api.Failure{Code: "terminal_unavailable"} + } + return err +} + +// Keep the terminal lifecycle separate from error presentation. This narrow +// boundary also covers initialization failure after the input entered raw mode. +type terminalProgram interface { + Run() (tea.Model, error) + Kill() +} + +func runTerminalProgram(program terminalProgram) (err error) { + defer func() { + program.Kill() + if recover() != nil { + err = &api.Failure{Code: "terminal_unavailable"} + } + }() + if _, err := program.Run(); err != nil { + return &api.Failure{Code: "terminal_unavailable"} + } + return nil +} + +// terminalOutput cancels on the first write error without retaining raw errors. +// Subsequent cleanup writes are still attempted if the channel recovers. +type terminalOutput struct { + destination io.Writer + cancel context.CancelFunc + mu sync.Mutex + broken bool +} + +func (w *terminalOutput) Write(value []byte) (int, error) { + w.mu.Lock() + n, err := w.destination.Write(value) + if err == nil && n != len(value) { + err = io.ErrShortWrite + } + if err != nil { + w.broken = true + } + w.mu.Unlock() + if err != nil { + w.cancel() + } + return n, err +} + +func (w *terminalOutput) failed() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.broken } + +// Preserve real terminal descriptor detection while observing output writes. +type terminalFileOutput struct { + *terminalOutput + term.File +} + +func (w terminalFileOutput) Write(value []byte) (int, error) { return w.terminalOutput.Write(value) } diff --git a/cli/internal/command/tui_test.go b/cli/internal/command/tui_test.go new file mode 100644 index 000000000..8d3a052c7 --- /dev/null +++ b/cli/internal/command/tui_test.go @@ -0,0 +1,118 @@ +package command + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" + "github.com/charmbracelet/x/term" +) + +func TestTUIGuardsBeforeClientAndNetwork(t *testing.T) { + for _, args := range [][]string{{"tui"}, {"tui", "--output", "json"}, {"tui", "--output", "invalid"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + var out, errOut bytes.Buffer + reads := 0 + code := Run(args, &out, &errOut, func(string) string { reads++; return "secret-token" }, "test") + if code != 2 || reads != 0 || out.Len() != 0 || strings.Contains(errOut.String(), "secret-token") || strings.Contains(errOut.String(), "\x1b") { + t.Fatalf("code=%d env=%d out=%q err=%q", code, reads, out.String(), errOut.String()) + } + }) + } +} + +func TestTUIHelpDoesNotReadConfiguration(t *testing.T) { + var out, errOut bytes.Buffer + code := Run([]string{"tui", "--help"}, &out, &errOut, func(string) string { t.Fatal("help read credentials"); return "" }, "test") + if code != 0 || !strings.Contains(out.String(), "--plain") || errOut.Len() != 0 { + t.Fatal(code, out.String(), errOut.String()) + } +} + +func TestProgramErrorIsBounded(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var out bytes.Buffer + err := runTUI(ctx, nil, nil, &out, func(string) string { return "secret-token" }, true) + var failure *api.Failure + if !errors.As(err, &failure) || failure.Code != "terminal_unavailable" || strings.Contains(out.String(), "secret-token") { + t.Fatal("unbounded program error", err, out.String()) + } +} + +type failedTerminalWrite struct { + short bool + calls int +} + +func (w *failedTerminalWrite) Write(value []byte) (int, error) { + w.calls++ + if w.short { + return len(value) - 1, nil + } + return 0, errors.New("secret-token private output failure") +} + +type injectedTerminalProgram struct { + run func() error + cleanup func() +} + +func (p injectedTerminalProgram) Run() (tea.Model, error) { return nil, p.run() } +func (p injectedTerminalProgram) Kill() { p.cleanup() } + +func TestFailedOutputCancelsAndCleansBeforeBoundedFailure(t *testing.T) { + for _, short := range []bool{false, true} { + t.Run(map[bool]string{false: "write error", true: "short write"}[short], func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + destination := &failedTerminalWrite{short: short} + output := &terminalOutput{destination: destination, cancel: cancel} + var events []string + program := injectedTerminalProgram{run: func() error { + _, err := output.Write([]byte("terminal frame")) + if err == nil || ctx.Err() == nil { + t.Fatal("injected output failure was not observable") + } + if short && !errors.Is(err, io.ErrShortWrite) { + t.Fatal("short write accepted", err) + } + events = append(events, "write failed") + return err + }, cleanup: func() { events = append(events, "cleanup") }} + err := runTerminalProgram(program) + events = append(events, "handle bounded error") + var failure *api.Failure + if destination.calls != 1 || !output.failed() || !errors.As(err, &failure) || failure.Code != "terminal_unavailable" || strings.Contains(err.Error(), "secret-token") { + t.Fatal("output failure escaped or was ignored", err) + } + if strings.Join(events, ",") != "write failed,cleanup,handle bounded error" { + t.Fatal("error handling preceded cleanup", events) + } + }) + } +} + +func TestTerminalOutputPreservesRealDescriptor(t *testing.T) { + observed := &terminalOutput{destination: os.Stdout, cancel: func() {}} + var output io.Writer = terminalFileOutput{terminalOutput: observed, File: os.Stdout} + file, ok := output.(term.File) + if !ok || file.Fd() != os.Stdout.Fd() { + t.Fatal("output observation hid terminal descriptor") + } +} + +func TestProgramPanicCleansBeforeBoundedFailure(t *testing.T) { + cleaned := false + err := runTerminalProgram(injectedTerminalProgram{run: func() error { panic("secret-token panic") }, cleanup: func() { cleaned = true }}) + var failure *api.Failure + if !cleaned || !errors.As(err, &failure) || failure.Code != "terminal_unavailable" || strings.Contains(err.Error(), "secret-token") { + t.Fatal("panic escaped before cleanup", err) + } +} diff --git a/cli/internal/tui/handoff.go b/cli/internal/tui/handoff.go new file mode 100644 index 000000000..53851feba --- /dev/null +++ b/cli/internal/tui/handoff.go @@ -0,0 +1,66 @@ +package tui + +import "strings" + +func (m *Model) handoffText() string { + args := []string{"workstream"} + if m.picker { + args = append(args, "me", "projects") + } else if m.detail { + if selected, ok := m.selectedRow(); ok { + if m.tab == submissions { + args = append(args, "submission", "show", selected.id) + } else { + verb := "show" + if m.section == 1 { + verb = "context" + } + if m.section == 2 { + verb = "requirements" + } + args = append(args, "task", verb, selected.task) + } + } + } else { + switch m.tab { + case ready: + args = append(args, "task", "ready", m.project) + case mine: + args = append(args, "me", "assignments", "--project", m.project, "--state", "active") + case submissions: + args = append(args, "me", "submissions", "--project", m.project) + } + } + if !m.detail { + args = append(args, "--limit", "50") + if m.pageCursor != "" { + args = append(args, "--cursor", m.pageCursor) + } + } + var out strings.Builder + out.WriteString("MANUAL CLI HANDOFF\nArguments shown separately; not executed or copied.\n") + if m.projectName != "" { + out.WriteString("Observed project name: " + m.clean(m.projectName) + "\n") + } + if m.filter != "" { + out.WriteString("Local filter (fetched page only): " + m.clean(m.filter) + "\n") + } + for _, arg := range args { + out.WriteString(" argument: " + m.clean(arg) + "\n") + } + if m.project != "" { + out.WriteString("\nEvidence: CLI handoff only - not available in this workbench.\n") + for _, arg := range []string{"workstream", "contributions", "--project", m.project} { + out.WriteString(" argument: " + m.clean(arg) + "\n") + } + } + if m.detail && m.tab == submissions { + if selected, ok := m.selectedRow(); ok { + out.WriteString("\nDetailed findings: external manual read only.\n") + for _, arg := range []string{"workstream", "submission", "findings", selected.id} { + out.WriteString(" argument: " + m.clean(arg) + "\n") + } + } + } + return out.String() +} diff --git a/cli/internal/tui/handoff_test.go b/cli/internal/tui/handoff_test.go new file mode 100644 index 000000000..4bcefec68 --- /dev/null +++ b/cli/internal/tui/handoff_test.go @@ -0,0 +1,42 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestHandoffUsesInertArgumentsAndNoExportReads(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + before := len(f.calls) + press(t, m, "c") + text := m.handoffText() + for _, want := range []string{"not executed or copied", "argument: task\n argument: ready", "Evidence: CLI handoff only", "argument: contributions\n argument: --project\n argument: " + projectID} { + if !strings.Contains(text, want) { + t.Fatal(text) + } + } + if len(f.calls) != before { + t.Fatal("list handoff dispatched a read") + } + press(t, m, "esc") + press(t, m, "tab") + press(t, m, "tab") + press(t, m, "enter") + before = len(f.calls) + press(t, m, "c") + text = m.handoffText() + if !strings.Contains(text, "argument: submission\n argument: findings\n argument: "+submissionID) || len(f.calls) != before { + t.Fatal("handoff dispatched a read", text) + } +} + +func TestHandoffPreservesPageCursorAsSeparateArgument(t *testing.T) { + m := boot(t, &fakeClient{}) + m.pageCursor = "opaque ' cursor; not shell" + text := m.handoffText() + if !strings.Contains(text, "argument: --limit\n argument: 50") || !strings.Contains(text, "argument: --cursor\n argument: opaque ' cursor; not shell") { + t.Fatal(text) + } +} diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go new file mode 100644 index 000000000..a80434b14 --- /dev/null +++ b/cli/internal/tui/model.go @@ -0,0 +1,297 @@ +// Package tui presents contributor observations through the existing public client. +package tui + +import ( + "context" + "strings" + "time" + "unicode/utf8" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +type tab int + +const ( + ready tab = iota + mine + submissions +) + +func (t tab) String() string { return [...]string{"READY", "MINE", "SUBMISSIONS"}[t] } + +type row struct { + id, task, project, title, status string +} + +// Model owns one fetched page and one exact detail, never an authority cache. +type Model struct { + client ReadClient + ctx context.Context + stop context.CancelFunc + cancel context.CancelFunc + token string + plain, noColor bool + width, height int + started, closed, blocked, picker, detail, loading bool + help, handoff, filtering bool + filter, savedFilter string + actor, project, projectName string + tab tab + section int + rows []row + selected, page int + cursor *string + pageCursor string + request request + generation uint64 + failure string + observed time.Time + task *api.ContributorTaskDetail + context *api.WorkContext + intake *api.SubmissionRequirements + submission *api.SubmissionHistory + viewport viewport.Model +} + +// New has no I/O; the first adequately sized terminal event starts actor binding. +func New(ctx context.Context, client ReadClient, token string, plain, noColor bool) *Model { + ctx, stop := context.WithCancel(ctx) + return &Model{ctx: ctx, stop: stop, client: client, token: token, plain: plain, + noColor: noColor || plain, picker: true, page: 1, viewport: viewport.New()} +} + +func (m *Model) Init() tea.Cmd { return nil } + +// Close cancels outstanding reads after the program has stopped, including errors. +func (m *Model) Close() { m.closed = true; m.invalidate(); m.stop() } + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + defer m.reflow() + if m.closed { + return m, nil + } + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = min(max(msg.Width, 1), 300), min(max(msg.Height, 1), 150) + if !m.started && !m.tooSmall() { + m.started = true + return m, m.begin(profileRead) + } + case response: + return m, m.receive(msg) + case tea.KeyPressMsg: + return m, m.key(msg) + } + return m, nil +} + +func (m *Model) tooSmall() bool { return m.width < 40 || m.height < 10 } + +func (m *Model) invalidate() { + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + m.generation++ + m.loading = false +} + +func (m *Model) clearDetail() { + m.detail, m.section = false, 0 + m.task, m.context, m.intake, m.submission = nil, nil, nil, nil + m.handoff = false + m.viewport.GotoTop() +} + +func (m *Model) clearPage() { + m.rows, m.cursor = nil, nil + m.pageCursor = "" + m.selected, m.page = 0, 1 + m.filter, m.savedFilter, m.failure = "", "", "" + m.filtering = false + m.observed = time.Time{} + m.clearDetail() +} + +func (m *Model) visible() []row { + if m.filter == "" { + return m.rows + } + rows := make([]row, 0, len(m.rows)) + for _, row := range m.rows { + if strings.Contains(strings.ToLower(m.clean(row.title)), strings.ToLower(m.clean(m.filter))) { + rows = append(rows, row) + } + } + return rows +} + +func (m *Model) selectedRow() (row, bool) { + rows := m.visible() + if m.selected < 0 || m.selected >= len(rows) { + return row{}, false + } + return rows[m.selected], true +} + +func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { + k := key.String() + if k == "ctrl+c" || (!m.filtering && k == "q") { + m.closed = true + m.invalidate() + m.stop() + return tea.Quit + } + if m.filtering { + switch k { + case "enter": + m.filtering = false + case "esc": + m.filter, m.filtering = m.savedFilter, false + case "backspace": + _, size := utf8.DecodeLastRuneInString(m.filter) + if size > 0 { + m.filter = m.filter[:len(m.filter)-size] + } + default: + if key.Text != "" && len(m.filter)+len(key.Text) <= 256 { + m.filter += key.Text + } + } + m.selected = 0 + return nil + } + if k == "?" { + m.help, m.handoff = !m.help, false + m.viewport.GotoTop() + return nil + } + if k == "esc" && (m.help || m.handoff) { + m.help, m.handoff = false, false + return nil + } + if m.help { + return m.scroll(k) + } + if m.blocked || m.tooSmall() { + return nil + } + if k == "g" && m.actor != "" { + m.invalidate() + m.clearPage() + m.picker = true + m.project, m.projectName = "", "" + return m.begin(projectsRead) + } + if k == "c" { + m.handoff = !m.handoff + m.viewport.GotoTop() + return nil + } + if m.handoff { + return m.scroll(k) + } + if k == "esc" && m.detail { + m.invalidate() + m.clearDetail() + m.failure = "" + return nil + } + if k == "tab" && !m.picker && !m.detail { + m.invalidate() + m.clearPage() + m.tab = (m.tab + 1) % 3 + return m.begin(pageRead) + } + if k == "r" { + if m.loading { + return nil + } + if m.detail { + m.context, m.intake, m.task, m.submission = nil, nil, nil, nil + m.section = 0 + return m.begin(m.detailKind()) + } + m.clearPage() + if m.actor == "" { + return m.begin(profileRead) + } + if m.picker { + return m.begin(projectsRead) + } + return m.begin(pageRead) + } + if m.loading || m.failure != "" { + return nil + } + if m.detail { + if k == "tab" && m.tab == mine { + m.section = (m.section + 1) % 3 + m.viewport.GotoTop() + switch m.section { + case 1: + return m.begin(contextRead) + case 2: + return m.begin(intakeRead) + } + } + return m.scroll(k) + } + switch k { + case "/": + m.filtering, m.savedFilter = true, m.filter + case "up": + m.selected = max(0, m.selected-1) + case "down": + m.selected = min(max(0, len(m.visible())-1), m.selected+1) + case "n": + if m.cursor != nil { + cursor := *m.cursor + m.rows, m.cursor = nil, nil + m.selected = 0 + m.page++ + m.filter = "" + if m.picker { + return m.beginCursor(projectsRead, cursor) + } + return m.beginCursor(pageRead, cursor) + } + case "enter": + if selected, ok := m.selectedRow(); ok { + if m.picker { + m.project, m.projectName = selected.id, selected.title + m.picker, m.tab = false, ready + m.clearPage() + return m.begin(pageRead) + } + m.clearDetail() + m.detail = true + return m.begin(m.detailKind()) + } + } + return nil +} + +func (m *Model) detailKind() readKind { + if m.tab == submissions { + return submissionRead + } + return taskRead +} + +func (m *Model) scroll(key string) tea.Cmd { + switch key { + case "down": + m.viewport.ScrollDown(1) + case "up": + m.viewport.ScrollUp(1) + case "pgdown": + m.viewport.ScrollDown(max(1, m.viewport.Height()-1)) + case "pgup": + m.viewport.ScrollUp(max(1, m.viewport.Height()-1)) + } + return nil +} diff --git a/cli/internal/tui/model_test.go b/cli/internal/tui/model_test.go new file mode 100644 index 000000000..d5e039270 --- /dev/null +++ b/cli/internal/tui/model_test.go @@ -0,0 +1,254 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +const ( + actorID = "10000000-0000-7000-8000-000000000001" + projectID = "20000000-0000-7000-8000-000000000001" + taskID = "30000000-0000-7000-8000-000000000001" + submissionID = "40000000-0000-7000-8000-000000000001" + policyID = "50000000-0000-7000-8000-000000000001" +) + +type fakeClient struct { + calls []string + profile func(context.Context) (api.Result[api.Profile], error) + projects func(context.Context, api.PageQuery) (api.Result[api.ActorProjectPage], error) + ready func(context.Context, string, int, *string) (api.Result[api.ReadyTaskPage], error) + task func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) + context func(context.Context, string) (api.Result[api.WorkContext], error) + intake func(context.Context, string) (api.Result[api.SubmissionRequirements], error) + assignmentQuery, submissionQuery api.PageQuery +} + +func (f *fakeClient) Profile(ctx context.Context) (api.Result[api.Profile], error) { + f.calls = append(f.calls, "profile") + if f.profile != nil { + return f.profile(ctx) + } + return api.Result[api.Profile]{Value: api.Profile{ActorProfileID: actorID}}, nil +} +func (f *fakeClient) MyProjects(ctx context.Context, q api.PageQuery) (api.Result[api.ActorProjectPage], error) { + f.calls = append(f.calls, "projects") + if f.projects != nil { + return f.projects(ctx, q) + } + return api.Result[api.ActorProjectPage]{Value: api.ActorProjectPage{Items: []api.ActorProject{{ProjectID: projectID, Name: "Project", Status: "active"}}}}, nil +} +func (f *fakeClient) ReadyTasks(ctx context.Context, p string, n int, c *string) (api.Result[api.ReadyTaskPage], error) { + f.calls = append(f.calls, "ready") + if f.ready != nil { + return f.ready(ctx, p, n, c) + } + return api.Result[api.ReadyTaskPage]{Value: api.ReadyTaskPage{ProjectID: p, Items: []api.ReadyTaskSummary{{TaskID: taskID, ProjectID: p, Title: "Task title"}}}}, nil +} +func (f *fakeClient) MyAssignments(_ context.Context, q api.PageQuery) (api.Result[api.ContributorAssignmentPage], error) { + f.calls = append(f.calls, "mine") + f.assignmentQuery = q + return api.Result[api.ContributorAssignmentPage]{Value: api.ContributorAssignmentPage{Items: []api.ContributorAssignment{{AssignmentID: actorID, ProjectID: projectID, TaskID: taskID, Title: "Assigned task", State: "active", AssignmentStatus: "active"}}}}, nil +} +func (f *fakeClient) MySubmissions(_ context.Context, q api.PageQuery) (api.Result[api.ContributorSubmissionPage], error) { + f.calls = append(f.calls, "submissions") + f.submissionQuery = q + return api.Result[api.ContributorSubmissionPage]{Value: api.ContributorSubmissionPage{Items: []api.ContributorSubmission{{SubmissionID: submissionID, ProjectID: projectID, TaskID: taskID, Title: "Attempt", Status: "review_pending"}}}}, nil +} +func taskDetail() api.ContributorTaskDetail { + return api.ContributorTaskDetail{TaskSummary: api.TaskSummary{TaskID: taskID, ProjectID: projectID, Title: "Task title", Status: "ready"}, Description: "Exact instructions", Compensation: api.CompensationTerms{ContributionPolicyVersionID: policyID, AcceptedSubmission: []byte(`"unpaid"`), CompletedReview: []byte(`"unpaid"`)}} +} +func (f *fakeClient) ContributorTask(ctx context.Context, id string) (api.Result[api.ContributorTaskDetail], error) { + f.calls = append(f.calls, "task") + if f.task != nil { + return f.task(ctx, id) + } + return api.Result[api.ContributorTaskDetail]{Value: taskDetail()}, nil +} +func (f *fakeClient) WorkContext(ctx context.Context, id string) (api.Result[api.WorkContext], error) { + f.calls = append(f.calls, "context") + if f.context != nil { + return f.context(ctx, id) + } + return api.Result[api.WorkContext]{Value: api.WorkContext{Task: taskDetail(), Project: api.ContextProject{ID: projectID}, Guide: api.ContextGuide{ID: actorID, ProjectID: projectID, Version: "locked-v1"}, ContributionPolicyVersionID: policyID}}, nil +} +func (f *fakeClient) SubmissionRequirements(ctx context.Context, id string) (api.Result[api.SubmissionRequirements], error) { + f.calls = append(f.calls, "intake") + if f.intake != nil { + return f.intake(ctx, id) + } + return api.Result[api.SubmissionRequirements]{Value: api.SubmissionRequirements{TaskID: taskID, ProjectID: projectID, GuideVersion: "locked-v1"}}, nil +} +func (f *fakeClient) Submission(context.Context, string) (api.Result[api.SubmissionHistory], error) { + f.calls = append(f.calls, "submission") + return api.Result[api.SubmissionHistory]{Value: api.SubmissionHistory{ID: submissionID, Task: taskID, Status: "review_pending", Summary: "Stored summary"}}, nil +} + +func apply(t *testing.T, m *Model, cmd tea.Cmd) { + t.Helper() + for n := 0; cmd != nil; n++ { + if n > 10 { + t.Fatal("unexpected automatic request loop") + } + _, cmd = m.Update(cmd()) + } +} +func press(t *testing.T, m *Model, key string) { + t.Helper() + _, cmd := m.Update(keyMessage(key)) + apply(t, m, cmd) +} +func keyMessage(key string) tea.KeyPressMsg { + switch key { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + case "tab": + return tea.KeyPressMsg{Code: tea.KeyTab} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "ctrl+c": + return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + default: + r := []rune(key) + return tea.KeyPressMsg{Code: r[0], Text: key} + } +} +func boot(t *testing.T, f *fakeClient) *Model { + t.Helper() + m := New(context.Background(), f, "secret-token", true, true) + t.Cleanup(m.Close) + _, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + apply(t, m, cmd) + return m +} + +func TestContributorReadNavigation(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + if strings.Join(f.calls, ",") != "profile,projects" { + t.Fatal(f.calls) + } + press(t, m, "enter") + press(t, m, "enter") + press(t, m, "tab") + if m.task == nil || strings.Join(f.calls, ",") != "profile,projects,ready,task" { + t.Fatal("Ready must not fetch context", f.calls) + } + press(t, m, "esc") + press(t, m, "tab") + if q := f.assignmentQuery; q.ProjectID == nil || *q.ProjectID != projectID || q.State == nil || *q.State != "active" || q.Limit != 50 { + t.Fatal(q) + } + press(t, m, "enter") + press(t, m, "tab") + press(t, m, "tab") + if m.context == nil || m.intake == nil { + t.Fatal("missing explicit context/intake") + } + press(t, m, "esc") + press(t, m, "tab") + press(t, m, "enter") + if m.submission == nil || f.submissionQuery.ProjectID == nil || f.submissionQuery.State != nil { + t.Fatal("missing owned attempt") + } +} + +func TestExplicitPaginationAndRefresh(t *testing.T) { + f := &fakeClient{} + cursor := "opaque/next?" + f.ready = func(_ context.Context, p string, n int, c *string) (api.Result[api.ReadyTaskPage], error) { + if n != 50 { + t.Fatal(n) + } + page := api.ReadyTaskPage{ProjectID: p, Items: []api.ReadyTaskSummary{{TaskID: taskID, ProjectID: p, Title: "Task"}}} + if c == nil { + page.NextCursor = &cursor + } else if *c != cursor { + t.Fatal(*c) + } + return api.Result[api.ReadyTaskPage]{Value: page}, nil + } + m := boot(t, f) + press(t, m, "enter") + if len(f.calls) != 3 { + t.Fatal("automatic page traversal", f.calls) + } + _, cmd := m.Update(keyMessage("n")) + _, duplicate := m.Update(keyMessage("n")) + if duplicate != nil { + t.Fatal("duplicate dispatch") + } + apply(t, m, cmd) + if len(m.rows) != 1 || m.page != 2 || m.cursor != nil { + t.Fatal("next must replace one page") + } + press(t, m, "r") + if m.page != 1 || m.cursor == nil { + t.Fatal("refresh must reset continuation") + } +} + +func TestFilterResizeHelpHandoffNeverRead(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + before := len(f.calls) + for _, k := range []string{"/", "Task", "enter", "?", "esc", "c", "esc"} { + press(t, m, k) + } + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if len(f.calls) != before { + t.Fatal(f.calls) + } + if m.filter != "Task" || !strings.Contains(m.View().Content, "local filter") { + t.Fatal(m.View().Content) + } +} + +func TestDenialPurgesAndQuitCancels(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + press(t, m, "enter") + f.task = func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) { + return api.Result[api.ContributorTaskDetail]{}, &api.Failure{Code: "not_found", Status: 404} + } + press(t, m, "r") + if len(m.rows) != 0 || m.task != nil || m.cursor != nil || m.projectName != "" || !strings.Contains(m.failure, "HTTP 404") { + t.Fatal("denial retained private data") + } + if strings.Contains(m.View().Content, "Exact instructions") { + t.Fatal("denied text visible") + } + press(t, m, "g") + if !m.picker || len(m.rows) != 1 { + t.Fatal("fresh picker unavailable") + } + press(t, m, "ctrl+c") + if !m.closed || !errors.Is(m.ctx.Err(), context.Canceled) { + t.Fatal("quit left read context active") + } +} + +func TestSmallTerminalDoesNotStartReads(t *testing.T) { + f := &fakeClient{} + m := New(context.Background(), f, "", true, true) + defer m.Close() + _, cmd := m.Update(tea.WindowSizeMsg{Width: 30, Height: 8}) + if cmd != nil || len(f.calls) != 0 { + t.Fatal("small screen requested data") + } + press(t, m, "q") + if !m.closed { + t.Fatal("small screen cannot quit") + } +} diff --git a/cli/internal/tui/requests.go b/cli/internal/tui/requests.go new file mode 100644 index 000000000..38cb1c368 --- /dev/null +++ b/cli/internal/tui/requests.go @@ -0,0 +1,264 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +// ReadClient is the bounded public read surface, satisfied directly by api.Client. +type ReadClient interface { + Profile(context.Context) (api.Result[api.Profile], error) + MyProjects(context.Context, api.PageQuery) (api.Result[api.ActorProjectPage], error) + ReadyTasks(context.Context, string, int, *string) (api.Result[api.ReadyTaskPage], error) + MyAssignments(context.Context, api.PageQuery) (api.Result[api.ContributorAssignmentPage], error) + MySubmissions(context.Context, api.PageQuery) (api.Result[api.ContributorSubmissionPage], error) + ContributorTask(context.Context, string) (api.Result[api.ContributorTaskDetail], error) + WorkContext(context.Context, string) (api.Result[api.WorkContext], error) + SubmissionRequirements(context.Context, string) (api.Result[api.SubmissionRequirements], error) + Submission(context.Context, string) (api.Result[api.SubmissionHistory], error) +} + +var _ ReadClient = (*api.Client)(nil) + +type readKind int + +const ( + profileRead readKind = iota + projectsRead + pageRead + taskRead + submissionRead + contextRead + intakeRead +) + +// request is an immutable identity carried through a non-cooperative response. +type request struct { + generation uint64 + actor, project, resource, task, cursor string + tab tab + kind readKind + page, section int +} + +type response struct { + request request + value any + err error +} + +func (m *Model) begin(kind readKind) tea.Cmd { return m.beginCursor(kind, "") } + +func (m *Model) beginCursor(kind readKind, cursor string) tea.Cmd { + m.invalidate() + if kind == projectsRead || kind == pageRead { + m.pageCursor = cursor + } + m.failure, m.loading = "", true + m.observed = time.Time{} + r := request{generation: m.generation, actor: m.actor, project: m.project, tab: m.tab, + kind: kind, page: m.page, section: m.section, cursor: cursor} + if selected, ok := m.selectedRow(); ok { + r.resource, r.task = selected.id, selected.task + } + m.request = r + ctx, cancel := context.WithCancel(m.ctx) + m.cancel = cancel + client := m.client + return func() (message tea.Msg) { + // Never hand an application panic (which may contain a bearer) to the + // renderer's diagnostic logger or process-wide panic machinery. + defer func() { + if recover() != nil { + message = response{r, nil, &api.Failure{Code: "request_failed"}} + } + }() + var value any + var err error + var continuation *string + if r.cursor != "" { + continuation = &r.cursor + } + q := api.PageQuery{Limit: 50, Cursor: continuation} + switch r.kind { + case profileRead: + var result api.Result[api.Profile] + result, err = client.Profile(ctx) + value = result.Value + case projectsRead: + var result api.Result[api.ActorProjectPage] + result, err = client.MyProjects(ctx, q) + value = result.Value + case pageRead: + q.ProjectID = &r.project + switch r.tab { + case ready: + var result api.Result[api.ReadyTaskPage] + result, err = client.ReadyTasks(ctx, r.project, 50, continuation) + value = result.Value + case mine: + state := "active" + q.State = &state + var result api.Result[api.ContributorAssignmentPage] + result, err = client.MyAssignments(ctx, q) + value = result.Value + case submissions: + var result api.Result[api.ContributorSubmissionPage] + result, err = client.MySubmissions(ctx, q) + value = result.Value + } + case taskRead: + var result api.Result[api.ContributorTaskDetail] + result, err = client.ContributorTask(ctx, r.task) + value = result.Value + case submissionRead: + var result api.Result[api.SubmissionHistory] + result, err = client.Submission(ctx, r.resource) + value = result.Value + case contextRead: + var result api.Result[api.WorkContext] + result, err = client.WorkContext(ctx, r.task) + value = result.Value + case intakeRead: + var result api.Result[api.SubmissionRequirements] + result, err = client.SubmissionRequirements(ctx, r.task) + value = result.Value + } + return response{r, value, err} + } +} + +func sameID(a, b string) bool { + return strings.EqualFold(strings.ReplaceAll(a, "-", ""), strings.ReplaceAll(b, "-", "")) +} + +func (m *Model) receive(msg response) tea.Cmd { + if msg.request != m.request || msg.request.generation != m.generation || !m.loading { + return nil + } + m.loading = false + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + if msg.err != nil { + m.failed(msg.err, msg.request.kind == profileRead) + return nil + } + bad := func() tea.Cmd { m.failed(&api.Failure{Code: "invalid_api_response"}, false); return nil } + r := msg.request + switch value := msg.value.(type) { + case api.Profile: + if r.kind != profileRead || value.ActorProfileID == "" { + return bad() + } + m.actor = value.ActorProfileID + return m.begin(projectsRead) + case api.ActorProjectPage: + if r.kind != projectsRead { + return bad() + } + for _, item := range value.Items { + m.rows = append(m.rows, row{id: item.ProjectID, project: item.ProjectID, title: item.Name, status: item.Status}) + } + m.cursor = value.Cursor + case api.ReadyTaskPage: + if r.kind != pageRead || r.tab != ready || !sameID(value.ProjectID, r.project) { + return bad() + } + for _, item := range value.Items { + if !sameID(item.ProjectID, r.project) { + return bad() + } + m.rows = append(m.rows, row{id: item.TaskID, task: item.TaskID, project: item.ProjectID, title: item.Title, status: "ready"}) + } + m.cursor = value.NextCursor + case api.ContributorAssignmentPage: + if r.kind != pageRead || r.tab != mine { + return bad() + } + for _, item := range value.Items { + if !sameID(item.ProjectID, r.project) || item.State != "active" { + return bad() + } + m.rows = append(m.rows, row{id: item.AssignmentID, task: item.TaskID, project: item.ProjectID, title: item.Title, status: item.AssignmentStatus}) + } + m.cursor = value.Cursor + case api.ContributorSubmissionPage: + if r.kind != pageRead || r.tab != submissions { + return bad() + } + for _, item := range value.Items { + if !sameID(item.ProjectID, r.project) { + return bad() + } + m.rows = append(m.rows, row{id: item.SubmissionID, task: item.TaskID, project: item.ProjectID, title: item.Title, status: item.Status}) + } + m.cursor = value.Cursor + case api.ContributorTaskDetail: + if r.kind != taskRead || !sameID(value.TaskID, r.task) || !sameID(value.ProjectID, r.project) { + return bad() + } + m.task = &value + case api.SubmissionHistory: + if r.kind != submissionRead || !sameID(value.ID, r.resource) || !sameID(value.Task, r.task) { + return bad() + } + m.submission = &value + case api.WorkContext: + if r.kind != contextRead || !sameID(value.Task.TaskID, r.task) || !sameID(value.Project.ID, r.project) || + !sameID(value.Task.ProjectID, r.project) || !sameID(value.Guide.ProjectID, r.project) || + (m.task != nil && !sameID(value.ContributionPolicyVersionID, m.task.Compensation.ContributionPolicyVersionID)) || + (m.intake != nil && value.Guide.Version != m.intake.GuideVersion) { + return bad() + } + m.context = &value + case api.SubmissionRequirements: + if r.kind != intakeRead || !sameID(value.TaskID, r.task) || !sameID(value.ProjectID, r.project) || + (m.context != nil && value.GuideVersion != m.context.Guide.Version) { + return bad() + } + m.intake = &value + default: + return bad() + } + m.observed = time.Now() + m.viewport.GotoTop() + return nil +} + +func (m *Model) failed(err error, actorRead bool) { + m.invalidate() + m.clearPage() + m.projectName = "" + var failure *api.Failure + if errors.As(err, &failure) { + if failure.Status == 401 || (actorRead && failure.Status == 403) { + m.blocked = true + m.actor = "" + m.project = "" + m.request = request{} + } + label := "Unavailable" + if failure.Status == 401 || failure.Status == 403 || failure.Status == 404 { + label = "Denied" + } + m.failure = label + ": " + m.clean(failure.Code) + if failure.Status != 0 { + m.failure += fmt.Sprintf(" (HTTP %d)", failure.Status) + } + if failure.CorrelationID != "" { + m.failure += " correlation " + m.clean(failure.CorrelationID) + } + } else { + m.failure = "Unavailable: request_failed" + } + // No old observation survives a fresh refusal or transport failure. + m.observed = time.Time{} +} diff --git a/cli/internal/tui/requests_test.go b/cli/internal/tui/requests_test.go new file mode 100644 index 000000000..e3d4acd7b --- /dev/null +++ b/cli/internal/tui/requests_test.go @@ -0,0 +1,152 @@ +package tui + +import ( + "context" + "strings" + "testing" + + "github.com/Flow-Research/workstream/cli/internal/api" +) + +func TestNonCooperativeLateSuccessCannotReplaceNewScope(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + _, cmd := m.Update(keyMessage("enter")) + late := cmd() // deliberately succeeds even after later cancellation + press(t, m, "esc") + press(t, m, "tab") + m.Update(late) + if m.tab != mine || m.detail || m.task != nil || len(m.rows) != 1 || m.rows[0].title != "Assigned task" { + t.Fatal("late task replaced Mine") + } +} + +func TestLateSuccessAfterDenialCannotResurrectData(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + _, cmd := m.Update(keyMessage("enter")) + late := cmd().(response) + m.Update(response{request: late.request, err: &api.Failure{Code: "revoked", Status: 403}}) + m.Update(late) + if m.task != nil || len(m.rows) > 0 || !strings.Contains(m.failure, "Denied") { + t.Fatal("late response resurrected data") + } +} + +func TestFresh401BlocksCachedNavigation(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + _, cmd := m.Update(keyMessage("enter")) + r := cmd().(response) + m.Update(response{request: r.request, err: &api.Failure{Code: "expired", Status: 401}}) + if m.project != "" || m.request != (request{}) || strings.Contains(m.View().Content, projectID) { + t.Fatal("authentication failure retained project or request selectors") + } + before := len(f.calls) + for _, k := range []string{"g", "r", "tab", "enter"} { + press(t, m, k) + } + if len(f.calls) != before || !m.blocked || m.actor != "" || len(m.rows) > 0 { + t.Fatal("authentication failure retried") + } +} + +func TestDetailIdentityAndLockedContextMismatchRefused(t *testing.T) { + for _, scenario := range []string{"task", "project", "policy", "guide"} { + t.Run(scenario, func(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + if scenario == "task" || scenario == "project" { + f.task = func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) { + v := taskDetail() + if scenario == "task" { + v.TaskID = actorID + } else { + v.ProjectID = actorID + } + return api.Result[api.ContributorTaskDetail]{Value: v}, nil + } + press(t, m, "enter") + } else { + press(t, m, "tab") + press(t, m, "enter") + if scenario == "policy" { + f.context = func(context.Context, string) (api.Result[api.WorkContext], error) { + return api.Result[api.WorkContext]{Value: api.WorkContext{Task: taskDetail(), Project: api.ContextProject{ID: projectID}, Guide: api.ContextGuide{ProjectID: projectID}, ContributionPolicyVersionID: actorID}}, nil + } + } + press(t, m, "tab") + if scenario == "guide" { + f.intake = func(context.Context, string) (api.Result[api.SubmissionRequirements], error) { + return api.Result[api.SubmissionRequirements]{Value: api.SubmissionRequirements{TaskID: taskID, ProjectID: projectID, GuideVersion: "different"}}, nil + } + press(t, m, "tab") + } + } + if m.failure == "" || m.task != nil || m.context != nil || m.intake != nil || len(m.rows) != 0 { + t.Fatal("mismatched identity/lineage accepted") + } + }) + } +} + +func TestRequestPanicIsBoundedWithoutBearerOrStack(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + f.task = func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) { + panic("secret-token panic and stack") + } + press(t, m, "enter") + if !strings.Contains(m.failure, "request_failed") || strings.Contains(m.View().Content, "secret-token") || strings.Contains(m.View().Content, "panic and stack") { + t.Fatal(m.View().Content) + } +} + +func TestResourceTaskSubstitutionInOwnedAttempt(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + press(t, m, "tab") + press(t, m, "tab") + _, cmd := m.Update(keyMessage("enter")) + msg := cmd().(response) + value := msg.value.(api.SubmissionHistory) + value.Task = actorID + msg.value = value + m.Update(msg) + if m.submission != nil || m.failure == "" { + t.Fatal("foreign task accepted") + } +} + +func TestTransientProfileFailureRetriesBindingBeforeProjects(t *testing.T) { + for _, scenario := range []string{"503", "panic"} { + t.Run(scenario, func(t *testing.T) { + f := &fakeClient{} + attempts := 0 + f.profile = func(context.Context) (api.Result[api.Profile], error) { + attempts++ + if attempts == 1 { + if scenario == "panic" { + panic("private profile error") + } + return api.Result[api.Profile]{}, &api.Failure{Code: "temporarily_unavailable", Status: 503} + } + return api.Result[api.Profile]{Value: api.Profile{ActorProfileID: actorID}}, nil + } + m := boot(t, f) + if m.actor != "" || strings.Join(f.calls, ",") != "profile" { + t.Fatal("projects read before binding", f.calls) + } + press(t, m, "r") + if m.actor != actorID || strings.Join(f.calls, ",") != "profile,profile,projects" || len(m.rows) != 1 { + t.Fatal("refresh skipped profile retry", f.calls) + } + }) + } +} diff --git a/cli/internal/tui/text.go b/cli/internal/tui/text.go new file mode 100644 index 000000000..3c5405b97 --- /dev/null +++ b/cli/internal/tui/text.go @@ -0,0 +1,24 @@ +package tui + +import ( + "fmt" + "strings" + "unicode" +) + +// clean treats every external string as text, including reflected credentials. +// Application-owned labels/newlines are composed only after this boundary. +func (m *Model) clean(value string) string { + if m.token != "" { + value = strings.ReplaceAll(value, m.token, "[redacted]") + } + var out strings.Builder + for _, r := range value { + if !unicode.IsPrint(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' { + fmt.Fprintf(&out, "\\u%04X", r) + } else { + out.WriteRune(r) + } + } + return out.String() +} diff --git a/cli/internal/tui/text_test.go b/cli/internal/tui/text_test.go new file mode 100644 index 000000000..f7112c2aa --- /dev/null +++ b/cli/internal/tui/text_test.go @@ -0,0 +1,19 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestExternalTextEscapesTerminalAndFormatControls(t *testing.T) { + m := &Model{token: "private-bearer"} + s := m.clean("private-bearer\x1b]52;c;YQ==\a\x1b]8;;https://evil\x1b\\\x1b[2J\u009b\u202e\u2028\u2029\n\t") + for _, raw := range []string{"private-bearer", "\x1b", "\a", "\u009b", "\u202e", "\u2028", "\u2029", "\n", "\t"} { + if strings.Contains(s, raw) { + t.Fatalf("unescaped %q in %q", raw, s) + } + } + if !strings.Contains(s, "[redacted]") || !strings.Contains(s, "\\u001B") { + t.Fatal(s) + } +} diff --git a/cli/internal/tui/view.go b/cli/internal/tui/view.go new file mode 100644 index 000000000..917f760b3 --- /dev/null +++ b/cli/internal/tui/view.go @@ -0,0 +1,319 @@ +package tui + +import ( + "fmt" + "math/big" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +func (m *Model) heading() string { + if m.picker { + return "PROJECTS" + } + if !m.detail { + return m.tab.String() + } + if m.tab == submissions { + return "SUBMISSION" + } + return [...]string{"TASK", "CONTEXT", "INTAKE"}[m.section] +} + +// fitText bounds a display label, never an exact identity or request selector. +// Callers pass text that has already crossed clean. +func fitText(value string, width int) string { + if lipgloss.Width(value) <= width { + return value + } + first, _, _ := strings.Cut(lipgloss.Wrap(value, max(1, width-3), ""), "\n") + return first + "..." +} + +func (m *Model) headerText() string { + header := "WORKSTREAM / CONTRIBUTE / " + m.heading() + if m.project != "" { + header += "\nProject: " + fitText(m.clean(m.projectName), m.width-9) + "\nID: " + m.clean(m.project) + } + return header +} + +func (m *Model) footerText() string { + if m.height < 16 { + return "? keys | q quit" + } + return "arrows select/scroll | enter inspect | tab section | esc back\ng project | n next | r refresh | / local filter | c CLI | ? help | q quit" +} + +func (m *Model) statusText() string { + status := fmt.Sprintf("page %d | fetched %d", m.page, len(m.rows)) + if m.width < 70 { + status = fmt.Sprintf("page %d rows %d", m.page, len(m.rows)) + } + if m.cursor != nil { + if m.width < 70 { + status += " more" + } else { + status += " | more available" + } + } + if !m.observed.IsZero() { + status += " observed " + m.observed.Format("15:04:05") + } + if m.filtering { + return fitText("Filter fetched page only: "+m.clean(m.filter), m.width) + "\n" + fitText(status, m.width) + } else if m.filter != "" { + return fitText("local filter: "+m.clean(m.filter), m.width) + "\n" + fitText(status, m.width) + } + return fitText(status, m.width) +} + +func (m *Model) View() tea.View { + if m.closed { + return tea.NewView("") + } + if m.tooSmall() { + v := tea.NewView(lipgloss.Wrap("Resize to at least 40x10. q quit", max(1, m.width), "")) + v.AltScreen = !m.plain + return v + } + header := m.headerText() + if !m.noColor { + header = lipgloss.NewStyle().Foreground(lipgloss.Color("#69D7E3")).Render(header) + } + footer, status := m.footerText(), m.statusText() + content := lipgloss.Wrap(header, m.width, "") + "\n" + m.viewport.View() + "\n" + + lipgloss.Wrap(status, m.width, "") + "\n" + lipgloss.Wrap(footer, m.width, "") + v := tea.NewView(content) + v.AltScreen = !m.plain + return v +} + +func (m *Model) reflow() { + if m.width < 1 || m.height < 1 { + return + } + // Reserve wrapped status/help lines; never measure unescaped server text. + reserved := lipgloss.Height(lipgloss.Wrap(m.headerText(), m.width, "")) + + lipgloss.Height(lipgloss.Wrap(m.footerText(), m.width, "")) + 1 + lipgloss.Height(m.statusText()) + m.viewport.SetWidth(m.width) + m.viewport.SetHeight(max(1, m.height-reserved)) + body := m.body() + m.viewport.SetContent(lipgloss.Wrap(body, m.width, "")) +} + +func (m *Model) body() string { + if m.help { + return "KEYBOARD HELP\nArrows select a row or scroll detail. Enter inspects one exact resource.\nTab: Ready/Mine/Submissions in lists; Overview/Context/Intake in active Mine detail.\nEsc returns without a write. g fetches a fresh project picker.\nn fetches exactly one next page. r resets the current read; no polling.\n/ filters the fetched page only; enter finishes, esc cancels.\nc shows inert CLI arguments. q or Ctrl+C quits.\nNo actions, downloads or exports run in this workbench.\nEvidence and detailed findings are manual CLI handoffs.\nA read is not a reservation or authority.\n? or Esc closes help." + } + if m.handoff { + return m.handoffText() + } + if m.failure != "" { + return m.failure + "\nNo retained page or detail is available.\n" + m.failureHelp() + } + if m.loading { + return "Loading..." + } + if !m.started { + return "Waiting for terminal size..." + } + if m.detail { + if m.width >= 100 && !m.plain { + leftWidth := m.width / 3 + return lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(leftWidth).Render(m.listText(leftWidth)), " | ", + lipgloss.NewStyle().Width(m.width-leftWidth-3).Render(lipgloss.Wrap(m.detailText(), m.width-leftWidth-3, ""))) + } + return m.detailText() + } + list := m.listText(m.width) + if m.width >= 100 && !m.plain && !m.picker { + leftWidth := m.width/2 - 2 + return lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(leftWidth).Render(m.listText(leftWidth)), + " | ", lipgloss.NewStyle().Width(m.width-leftWidth-3).Render("SELECTED TASK\nOpen to inspect.\nA summary is not full instructions or a reservation.")) + } + return list +} + +func (m *Model) failureHelp() string { + if m.blocked { + return "Session unavailable. Quit and supply a valid bearer through the existing environment.\n? help | q quit" + } + return "r explicit refresh | g fresh project picker | ? help | q quit" +} + +func (m *Model) listText(width int) string { + rows := m.visible() + if len(rows) == 0 { + return "No rows on this fetched page.\nThis is not a project-wide count." + } + var out strings.Builder + // Keep the selected row in the list viewport without retaining other pages. + first := max(0, m.selected-max(1, m.viewport.Height()-3)) + for i := first; i < len(rows); i++ { + mark := " " + if i == m.selected { + mark = "> " + } + status := m.clean(rows[i].status) + available := max(1, width-lipgloss.Width(status)-5) + title := m.clean(rows[i].title) + title = fitText(title, available) + fmt.Fprintf(&out, "%s%s [%s]\n", mark, title, status) + } + return out.String() +} + +func number(v *big.Int) string { + if v == nil { + return "not supplied" + } + return v.String() +} + +func (m *Model) detailText() string { + var out strings.Builder + line := func(label, value string) { out.WriteString(label + ": " + m.clean(value) + "\n") } + optional := func(label string, value *string) { + if value != nil { + line(label, *value) + } + } + if m.submission != nil { + v := m.submission + line("Submission", v.ID) + line("Task", v.Task) + line("Stored outcome", v.Status) + optional("Reason", v.StatusReason) + line("Version", number(v.Version)) + line("Summary", v.Summary) + line("Submitted", v.SubmittedAt) + optional("Decided", v.DecidedAt) + line("Blocking findings", number(v.FindingCounts.Blocking)) + line("Warning findings", number(v.FindingCounts.Warning)) + line("Coverage incomplete", number(v.FindingCounts.CoverageIncomplete)) + optional("Predecessor", v.Predecessor) + for _, e := range v.Evidence { + line("Evidence label", e.Label) + line("Evidence ID", e.ID) + line("Type", e.Type) + line("Size", number(e.Size)) + } + out.WriteString("Detailed findings: manual CLI handoff only (c).\nStored observations do not predict acceptance or fulfillment.") + return out.String() + } + if m.section == 1 && m.context != nil { + v := m.context + line("Task", v.Task.TaskID) + line("Project", v.Project.ID) + line("Guide", v.Guide.ID) + line("Locked guide version", v.Guide.Version) + line("Review policy", v.ReviewPolicy.PolicyID) + line("Review policy generation", fmt.Sprint(v.ReviewPolicy.Generation)) + line("Review policy hash", v.ReviewPolicy.PolicyHash) + line("Revision policy", v.RevisionPolicy.PolicyID) + line("Revision policy generation", fmt.Sprint(v.RevisionPolicy.Generation)) + line("Revision policy hash", v.RevisionPolicy.PolicyHash) + line("Contribution policy version", v.ContributionPolicyVersionID) + line("Assigned to current actor", fmt.Sprint(v.Lifecycle.AssignedToCurrentActor)) + line("Observed action hints (not authority)", strings.Join(v.Lifecycle.NextActions, ", ")) + for _, d := range v.GuideDocuments { + line("Document label", d.Label) + line("Document ID", d.DocumentID) + line("Media type", d.MediaType) + line("Bytes", fmt.Sprint(d.ByteCount)) + line("SHA-256 (server metadata; bytes not downloaded)", d.SHA256) + } + if v.RevisionContext != nil { + r := v.RevisionContext + line("Revision preparation", r.PreparationID) + line("Revision preparation digest", r.PreparationSHA256) + line("Revision source Submission", r.SourceSubmissionID) + line("Revision disposition", r.Disposition) + line("Revision clock", r.ClockState) + optional("Revision deadline", r.DeadlineAt) + } + return out.String() + } + if m.section == 2 && m.intake != nil { + v := m.intake + line("Task", v.TaskID) + line("Project", v.ProjectID) + line("Locked guide version", v.GuideVersion) + line("Required packet fields", strings.Join(v.RequiredPacketFields, ", ")) + for _, a := range v.RequiredArtifacts { + line("Artifact", a.Key) + line("Path (text only)", a.Path) + line("Required", fmt.Sprint(a.Required)) + line("Hash required", fmt.Sprint(a.HashRequired)) + optional("Description", a.Description) + } + for _, e := range v.RequiredEvidence { + line("Evidence", e.Key) + line("Label", e.Label) + line("Required", fmt.Sprint(e.Required)) + line("Hash required", fmt.Sprint(e.HashRequired)) + optional("Description", e.Description) + } + for _, a := range v.ForbiddenArtifacts { + line("Forbidden pattern", a.Pattern) + optional("Reason", a.Reason) + optional("Suggested fix", a.WorkerFacingFix) + optional("Severity", a.Severity) + } + for _, term := range v.AttestationTerms { + line("Attestation term", term) + } + line("Manifest required", fmt.Sprint(v.ManifestRequired)) + line("Artifact hash required", fmt.Sprint(v.ArtifactHashRequired)) + line("Hash algorithm", v.ArtifactHashAlgorithm) + line("Allowed storage schemes", strings.Join(v.AllowedStorageSchemes, ", ")) + line("Allowed URI prefixes (text only)", strings.Join(v.StorageReferenceRules.AllowedURIPrefixes, ", ")) + line("Storage credentials allowed", fmt.Sprint(v.StorageReferenceRules.CredentialsAllowed)) + line("Storage query strings allowed", fmt.Sprint(v.StorageReferenceRules.QueryStringsAllowed)) + line("Storage fragments allowed", fmt.Sprint(v.StorageReferenceRules.FragmentsAllowed)) + line("Storage path traversal allowed", fmt.Sprint(v.StorageReferenceRules.PathTraversalAllowed)) + line("Maximum file bytes", number(v.MaximumFileSizeBytes)) + line("Maximum package bytes", number(v.MaximumPackageSizeBytes)) + line("Maximum archive bytes", number(v.MaximumArchiveSizeBytes)) + line("Maximum archive entries", number(v.MaximumArchiveEntries)) + line("Package required", fmt.Sprint(v.Packaging.PackageRequired)) + line("Allowed package formats", strings.Join(v.Packaging.AllowedPackageFormats, ", ")) + out.WriteString("Intake requirements are not a preparation or Submission.\nNo package is uploaded.") + return out.String() + } + if m.task != nil { + v := m.task + line("Task", v.TaskID) + line("Title", v.Title) + line("Status", v.Status) + line("Instructions", v.Description) + optional("Acceptance criteria", v.AcceptanceCriteria) + optional("Rejection criteria", v.RejectionCriteria) + line("Skills", strings.Join(v.SkillTags, ", ")) + line("Contribution policy version", v.Compensation.ContributionPolicyVersionID) + for _, terms := range []struct { + label string + raw []byte + }{{"Accepted submission terms", v.Compensation.AcceptedSubmission}, {"Completed review terms", v.Compensation.CompletedReview}} { + awards, ok := api.CompensationAwards(terms.raw) + if !ok { + line(terms.label, "unavailable") + continue + } + if len(awards) == 0 { + line(terms.label, "unpaid") + } + for _, a := range awards { + line(terms.label, a.Instrument+" / "+a.Unit+" / "+a.Quantity) + } + } + out.WriteString("Terms are potential earned terms, not acceptance or fulfillment.\nNo action can be sent from this workbench.") + } + return out.String() +} diff --git a/cli/internal/tui/view_test.go b/cli/internal/tui/view_test.go new file mode 100644 index 000000000..dbb9c78bf --- /dev/null +++ b/cli/internal/tui/view_test.go @@ -0,0 +1,74 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +func TestResizePreservesSelectionAndBoundsCells(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + press(t, m, "enter") + m.task.Description = strings.Repeat("長い é words ", 1000) + m.projectName = strings.Repeat("hostile project name ", 1000) + for _, plain := range []bool{true, false} { + m.plain, m.noColor = plain, true + for _, size := range [][2]int{{120, 40}, {80, 24}, {60, 18}, {40, 10}, {30, 8}, {100000, 100000}} { + m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + view := m.View() + if m.task == nil || m.task.TaskID != taskID { + t.Fatal("resize lost identity") + } + if size[0] >= 40 { + for _, line := range strings.Split(view.Content, "\n") { + if lipgloss.Width(line) > m.width { + t.Fatalf("line exceeds %d: %q", m.width, line) + } + } + if lipgloss.Height(view.Content) > m.height { + t.Fatalf("view exceeds %d rows: %d", m.height, lipgloss.Height(view.Content)) + } + } + if view.AltScreen != !plain || strings.Contains(view.Content, "\x1b") { + t.Fatal("plain/no-color view has incorrect screen mode or color") + } + } + } +} + +func TestPotentialTermsAndStoredOutcomeAreDistinct(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + press(t, m, "enter") + m.task.Compensation = api.CompensationTerms{ContributionPolicyVersionID: policyID, AcceptedSubmission: []byte(`[{"instrument":"money","unit":"USD","quantity":"0.100000000000000001"}]`), CompletedReview: []byte(`"unpaid"`)} + text := m.detailText() + for _, want := range []string{"0.100000000000000001", "Completed review terms: unpaid", "not acceptance or fulfillment"} { + if !strings.Contains(text, want) { + t.Fatal(text) + } + } + press(t, m, "esc") + press(t, m, "tab") + press(t, m, "tab") + press(t, m, "enter") + if !strings.Contains(m.detailText(), "Stored outcome: review_pending") { + t.Fatal(m.detailText()) + } +} + +func TestNoColorRetainsSelectionAndStatusLabels(t *testing.T) { + m := boot(t, &fakeClient{}) + m.plain = false + m.noColor = true + press(t, m, "enter") + v := m.View() + if strings.Contains(v.Content, "\x1b") || !strings.Contains(v.Content, "> Task title [ready]") { + t.Fatal(v.Content) + } +} diff --git a/cli/tests/integration/http2_fixture.py b/cli/tests/integration/http2_fixture.py index 90fbd4319..75d58879a 100644 --- a/cli/tests/integration/http2_fixture.py +++ b/cli/tests/integration/http2_fixture.py @@ -12,11 +12,6 @@ import ssl from threading import Event, Thread -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.x509.oid import NameOID - PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" @@ -70,6 +65,11 @@ def receive_request(connection): @contextmanager def goaway_fixture(tmp_path): """Observe every fresh TLS connection until the built CLI exits.""" + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + key = ec.generate_private_key(ec.SECP256R1()) name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "CLI test loopback")]) now = datetime.now(timezone.utc) diff --git a/cli/tests/integration/requirements-native.txt b/cli/tests/integration/requirements-native.txt new file mode 100644 index 000000000..06244c9f4 --- /dev/null +++ b/cli/tests/integration/requirements-native.txt @@ -0,0 +1,17 @@ +# Native terminal socket proof only: CPython 3.12 on the four release targets. +# Actual wheel METADATA and SHA-256 inspected at https://pypi.org/pypi/NAME/VERSION/json +# on 2026-10-23. These packages have universal wheels; colorama is Windows-only. +# The real HTTP2/TLS fixture loads backend cryptography only when invoked. +# Install in a fresh venv with pip --isolated --require-hashes --only-binary=:all:. +pytest==9.1.1 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c +iniconfig==2.3.0 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pluggy==1.6.0 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +pygments==2.20.0 \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 +colorama==0.4.6 ; sys_platform == "win32" \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py new file mode 100644 index 000000000..b37cfba00 --- /dev/null +++ b/cli/tests/integration/test_tui_http.py @@ -0,0 +1,632 @@ +"""Built TUI -> native PTY/ConPTY -> adversarial socket; no runtime substitute.""" + +from copy import deepcopy +import os +import re +import subprocess +from threading import Event +from urllib.parse import parse_qs, urlsplit + +import pytest + +from test_contributor_reads_http import ASSIGNMENT_ROW, PROJECT_ROW, SUBMISSION_ROW +from test_contributor_task_http import CONTRIBUTOR, READY +from test_http_boundary import ACTOR, PROFILE, PROJECT, TOKEN +from test_submission_http import HISTORY, SUBMISSION +from test_task_context_http import CONTEXT, REQUIREMENTS +from test_task_http_boundary import TASK +from tui_terminal_support import Terminal, environment, executable, socket_peer + +OTHER_PROJECT = "018f0ebc-7966-7e8d-bc4d-1cae1e000099" +OTHER_TASK = "018f0ebc-7966-7e8d-bc4d-1cae1e000098" +CURSOR = "opaque+/=&?#next" +READY_PATH = f"/api/v1/projects/{PROJECT}/tasks/ready" +TASK_PATH = f"/api/v1/tasks/{TASK}" +SECRET = "EXACT_PRIVATE_INSTRUCTIONS" +HOSTILE = "label\x1b]52;c;c2VjcmV0\x07\x1b]8;;https://evil.invalid\x1b\\link\x1b]8;;\x1b\\\x1b[31m\u202e\u2066\x85\u2028" + + +def page(row, cursor=None): + return {"items": [row], "next_cursor": cursor} + + +class Reads: + """Only the nine existing public reads; unexpected routes/writes fail.""" + + def __init__(self): + self.projects = page(PROJECT_ROW | {"name": "Terminal project"}) + self.ready = page(READY | {"title": "Ready terminal task"}, CURSOR) | { + "project_id": PROJECT + } + self.mine = page( + ASSIGNMENT_ROW + | {"title": "Assigned terminal task", "locked_guide_version": "guide-1"} + ) + self.submissions = page( + SUBMISSION_ROW + | {"title": "Owned terminal attempt", "submission_id": SUBMISSION} + ) + self.task = CONTRIBUTOR | { + "title": "Fresh terminal task", + "description": SECRET, + } + self.context = deepcopy(CONTEXT) + self.context["task"] = self.task + self.context["contribution_policy_version_id"] = self.task["compensation"][ + "contribution_policy_version_id" + ] + self.requirements = deepcopy(REQUIREMENTS) + self.submission = HISTORY | { + "task_id": TASK, + "summary": "Exact owned submission summary", + } + self.overrides = {} + + def __call__(self, method, target, authorization): + assert method == "GET", "TUI attempted product mutation" + assert authorization == "Bearer " + TOKEN + parsed = urlsplit(target) + path = parsed.path + query = parse_qs(parsed.query) + routes = { + "/api/v1/actors/me": PROFILE, + "/api/v1/actors/me/projects": self.projects, + READY_PATH: self.ready, + "/api/v1/actors/me/assignments": self.mine, + "/api/v1/actors/me/submissions": self.submissions, + TASK_PATH: self.task, + TASK_PATH + "/work-context": self.context, + TASK_PATH + "/submission-requirements": self.requirements, + f"/api/v1/submissions/{SUBMISSION}": self.submission, + } + assert path in routes or path in self.overrides, f"unlisted TUI read: {path}" + if path in self.overrides: + return self.overrides[path](query) + if path == "/api/v1/actors/me/projects": + assert query == {"limit": ["50"]} + elif path == READY_PATH: + assert query in ({"limit": ["50"]}, {"limit": ["50"], "cursor": [CURSOR]}) + elif path == "/api/v1/actors/me/assignments": + assert query == { + "limit": ["50"], + "project_id": [PROJECT], + "state": ["active"], + } + elif path == "/api/v1/actors/me/submissions": + assert query == {"limit": ["50"], "project_id": [PROJECT]} + else: + assert query == {} + return 200, routes[path] + + +def open_ready(terminal, project_name="Terminal project"): + terminal.wait(project_name) + terminal.send("\r") + terminal.wait("Ready terminal task") + + +def denial(status=404): + return status, { + "error": { + "code": "resource_not_found", + "message": TOKEN + HOSTILE, + "correlation_id": ACTOR, + "details": {}, + "retryable": False, + } + } + + +def test_keyboard_journey_uses_only_nine_fresh_public_reads(): + reads = Reads() + with ( + socket_peer(reads) as (origin, requests), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + open_ready(terminal) + assert len(requests) == 3 # actor binding, membership, one selected list + terminal.send("?") + terminal.wait("KEYBOARD HELP") + assert len(requests) == 3 + terminal.send("\x1b") + terminal.wait("Ready terminal task") + terminal.send("\r") + terminal.wait(SECRET) + assert TASK in terminal.screen and PROJECT in terminal.screen + assert "2.125000000000000001" in terminal.screen + assert "unpaid" in terminal.screen + terminal.send("\t") + terminal.pump(0.2) + assert len(requests) == 4, "Ready eagerly fetched assignment context" + terminal.send("c") + terminal.wait("workstream") + assert "task" in terminal.screen and TASK in terminal.screen + assert len(requests) == 4, "inert argv issued a request" + terminal.send("c\x1b") + terminal.wait("Ready terminal task") + terminal.send("\t") + terminal.wait("Assigned terminal task") + terminal.send("\r") + terminal.wait(SECRET) + terminal.send("\t") + terminal.wait("CONTEXT") + terminal.wait("guide-1") + terminal.send("\t") + terminal.wait("INTAKE") + terminal.wait("answer.md") + terminal.send("\x1b") + terminal.wait("Assigned terminal task") + terminal.send("\t") + terminal.wait("Owned terminal attempt") + terminal.send("\r") + terminal.wait("Exact owned submission summary") + assert SUBMISSION in terminal.screen and TASK in terminal.screen + terminal.send("c") + terminal.wait("findings") + assert "manual" in terminal.screen.lower() + terminal.quit() + assert [urlsplit(target).path for _, target, _ in requests] == [ + "/api/v1/actors/me", + "/api/v1/actors/me/projects", + READY_PATH, + TASK_PATH, + "/api/v1/actors/me/assignments", + TASK_PATH, + TASK_PATH + "/work-context", + TASK_PATH + "/submission-requirements", + "/api/v1/actors/me/submissions", + f"/api/v1/submissions/{SUBMISSION}", + ] + assert TOKEN.encode() not in terminal.raw + + +def test_startup_retry_binds_profile_before_any_project_read(): + reads = Reads() + profile_reads = 0 + + def profile(_): + nonlocal profile_reads + profile_reads += 1 + return denial(503) if profile_reads == 1 else (200, PROFILE) + + reads.overrides["/api/v1/actors/me"] = profile + with ( + socket_peer(reads) as (origin, requests), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + terminal.wait("Unavailable") + terminal.pump(0.2) + assert len(requests) == 1, "failed actor binding must not fetch projects" + terminal.send("r") + terminal.wait("Terminal project") + assert [urlsplit(target).path for _, target, _ in requests] == [ + "/api/v1/actors/me", + "/api/v1/actors/me", + "/api/v1/actors/me/projects", + ] + terminal.quit() + + +def test_explicit_pagination_refresh_and_local_filter_do_not_walk_pages(): + reads = Reads() + + def ready(query): + assert query.get("limit") == ["50"] + if "cursor" in query: + assert query["cursor"] == [CURSOR] + return 200, page(READY | {"title": "Second page only"}) | { + "project_id": PROJECT + } + return 200, reads.ready + + reads.overrides[READY_PATH] = ready + with ( + socket_peer(reads) as (origin, requests), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + open_ready(terminal) + terminal.pump(0.2) + assert len(requests) == 3 + terminal.send("/nothing-matches\r") + terminal.pump(0.2) + assert "Ready terminal task" not in terminal.screen + assert "local" in terminal.screen.lower() + assert len(requests) == 3 + terminal.send("/\x1b") + terminal.pump(0.2) # a lone Escape must not combine with n as Alt+n + terminal.send("n") + terminal.wait("Second page only") + assert "Ready terminal task" not in terminal.screen + terminal.send("n") + terminal.pump(0.2) + assert len(requests) == 4, "no continuation must not repeat/walk a page" + terminal.send("r") + terminal.wait("Ready terminal task") + assert parse_qs(urlsplit(requests[-1][1]).query) == {"limit": ["50"]} + terminal.quit() + + +@pytest.mark.parametrize("status", [401, 403, 404, 503]) +def test_fresh_failure_clears_private_detail_and_never_prints_reflected_body(status): + reads = Reads() + with ( + socket_peer(reads) as (origin, requests), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + open_ready(terminal) + terminal.send("\r") + terminal.wait(SECRET) + reads.overrides[TASK_PATH] = lambda _: denial(status) + terminal.send("r") + terminal.wait("Unavailable" if status == 503 else "Denied") + assert SECRET not in terminal.screen + assert TOKEN.encode() not in terminal.raw + assert b"\x1b]52;c;" not in terminal.raw + before = len(requests) + if status == 401: + assert PROJECT not in terminal.screen + terminal.send("g\tnr\r") + terminal.pump(0.2) + assert len(requests) == before, "auth-failed session retried automatically" + terminal.quit() + + +@pytest.mark.parametrize( + "field,replacement", [("task_id", OTHER_TASK), ("project_id", OTHER_PROJECT)] +) +def test_fresh_task_identity_substitution_is_not_rendered(field, replacement): + reads = Reads() + reads.task = reads.task | { + field: replacement, + "description": "SUBSTITUTED_PRIVATE_CONTENT", + } + with ( + socket_peer(reads) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + open_ready(terminal) + terminal.send("\r") + terminal.wait("Unavailable") + assert "SUBSTITUTED_PRIVATE_CONTENT" not in terminal.screen + terminal.quit() + + +@pytest.mark.parametrize("second_denied", [False, True]) +def test_late_project_success_cannot_replace_current_project_or_resurrect_denial( + second_denied, +): + reads = Reads() + reads.projects["items"].append( + PROJECT_ROW | {"project_id": OTHER_PROJECT, "name": "Second project"} + ) + started, release, finished = Event(), Event(), Event() + + def delayed(_): + started.set() + assert release.wait(10), "test failed to release held HTTP response" + finished.set() + return 200, reads.ready | {"items": [READY | {"title": "STALE_PROJECT_SECRET"}]} + + reads.overrides[READY_PATH] = delayed + reads.overrides[f"/api/v1/projects/{OTHER_PROJECT}/tasks/ready"] = lambda _: ( + denial() + if second_denied + else ( + 200, + page(READY | {"project_id": OTHER_PROJECT, "title": "Current project row"}) + | {"project_id": OTHER_PROJECT}, + ) + ) + with ( + socket_peer(reads) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + try: + terminal.wait("Terminal project") + terminal.send("\r") + assert started.wait(5) + terminal.send("g") + terminal.wait("Second project") + terminal.send("\x1b[B\r") + terminal.wait("Denied" if second_denied else "Current project row") + release.set() + assert finished.wait(5) + terminal.pump(0.3) + assert "STALE_PROJECT_SECRET" not in terminal.screen + assert ( + "Denied" if second_denied else "Current project row" + ) in terminal.screen + terminal.quit() + finally: + release.set() + + +@pytest.mark.parametrize( + "surface,field", + [ + ("submission", "id"), + ("submission", "task_id"), + ("context", "contribution_policy_version_id"), + ("context", "guide_project"), + ("intake", "task_id"), + ("intake", "project_id"), + ("intake", "guide_version"), + ], +) +def test_detail_sections_refuse_cross_resource_and_locked_lineage_substitution( + surface, field +): + reads = Reads() + if surface == "submission": + reads.submission[field] = OTHER_TASK + reads.submission["summary"] = "SUBSTITUTED_PRIVATE_CONTENT" + elif surface == "context": + if field == "guide_project": + reads.context["guide"]["project_id"] = OTHER_PROJECT + else: + reads.context[field] = OTHER_TASK + reads.context["guide"]["change_summary"] = "SUBSTITUTED_PRIVATE_CONTENT" + else: + reads.requirements[field] = ( + "wrong-guide" if field == "guide_version" else OTHER_TASK + ) + reads.requirements["attestation_terms"] = ["SUBSTITUTED_PRIVATE_CONTENT"] + with ( + socket_peer(reads) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + open_ready(terminal) + terminal.send("\t") + terminal.wait("Assigned terminal task") + if surface == "submission": + terminal.send("\t") + terminal.wait("Owned terminal attempt") + terminal.send("\r") + else: + terminal.send("\r") + terminal.wait(SECRET) + terminal.send("\t") + if surface == "intake": + terminal.wait("guide-1") + terminal.send("\t") + terminal.wait("Unavailable") + assert "SUBSTITUTED_PRIVATE_CONTENT" not in terminal.screen + assert SECRET not in terminal.screen + terminal.quit() + + +@pytest.mark.parametrize( + "columns,rows,args,extra", + [ + (120, 40, (), {}), + (80, 24, (), {"NO_COLOR": "1"}), + (60, 18, ("--plain",), {}), + ], +) +def test_hostile_text_resizing_plain_and_ctrl_c_restore_terminal( + columns, rows, args, extra +): + reads = Reads() + reads.projects["items"][0]["name"] = "Terminal" + HOSTILE + reads.task["description"] = SECRET + HOSTILE + with ( + socket_peer(reads) as (origin, requests), + Terminal( + origin, TOKEN, *args, columns=columns, rows=rows, extra_env=extra + ) as terminal, + ): + open_ready(terminal, "Terminal") + terminal.send("\r") + terminal.wait(SECRET) + before = len(requests) + terminal.resize(80, 24) + terminal.wait(SECRET) + assert TASK in terminal.screen + assert len(requests) == before + for forbidden in ( + b"\x1b]52;c;", + b"\x1b]8;;https://evil", + b"\x1b[31m", + "\u202e".encode(), + "\u2066".encode(), + b"\xc2\x85", + "\u2028".encode(), + ): + assert forbidden not in terminal.raw + assert TOKEN.encode() not in terminal.raw + if args: + assert b"\x1b[?1049h" not in terminal.raw + if args or extra: + assert not re.search( + rb"\x1b\[[0-9;]*(?:3[0-9]|4[0-9]|[34]8)[0-9;]*m", terminal.raw + ) + terminal.quit(b"\x03") + + +@pytest.mark.parametrize("surface", ["profile", "project", "task"]) +def test_reflected_bearer_in_success_body_is_never_displayed(surface): + reads = Reads() + if surface == "profile": + reads.overrides["/api/v1/actors/me"] = lambda _: ( + 200, + PROFILE | {"display_name": TOKEN}, + ) + elif surface == "project": + reads.projects["items"][0]["name"] += TOKEN + else: + reads.task["description"] += TOKEN + with ( + socket_peer(reads) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + if surface == "task": + open_ready(terminal) + terminal.send("\r") + terminal.wait( + { + "profile": "Terminal project", + "project": "Unavailable", + "task": "[redacted]", + }[surface] + ) + assert TOKEN.encode() not in terminal.raw + if surface != "task": + assert SECRET not in terminal.screen + terminal.quit() + + +def test_too_small_terminal_makes_no_requests_until_usable_resize(): + reads = Reads() + with ( + socket_peer(reads) as (origin, requests), + Terminal(origin, TOKEN, "--plain", columns=30, rows=8) as terminal, + ): + terminal.wait("Resize") + terminal.pump(0.2) + assert requests == [] + terminal.resize(80, 24) + terminal.wait("Terminal project") + terminal.quit() + + +def test_invalid_configuration_leaves_interactive_terminal_untouched(): + with ( + socket_peer(Reads()) as (origin, requests), + Terminal( + origin, + TOKEN, + extra_env={"WORKSTREAM_API_URL": "http://user:private-origin@127.0.0.1"}, + ) as terminal, + ): + terminal.wait_exit(expected=2) + assert requests == [] + terminal.assert_no_terminal_controls() + assert b"private-origin" not in terminal.raw + assert TOKEN.encode() not in terminal.raw + + +def test_quit_while_http_read_is_pending_restores_terminal_without_late_redraw(): + reads = Reads() + started, release = Event(), Event() + + def delayed(_): + started.set() + assert release.wait(10) + return 200, reads.ready + + reads.overrides[READY_PATH] = delayed + with socket_peer(reads) as (origin, _), Terminal(origin, TOKEN) as terminal: + try: + terminal.wait("Terminal project") + terminal.send("\r") + assert started.wait(5) + terminal.quit() + output_at_exit = bytes(terminal.raw) + release.set() + terminal.pump(0.2) + assert bytes(terminal.raw) == output_at_exit + finally: + release.set() + + +if os.name == "posix": + + def test_posix_sigterm_restores_terminal_after_rendering_private_detail(): + """OS-signal teardown proof, distinct from injected program-error proof.""" + import signal + import termios + + with ( + socket_peer(Reads()) as (origin, requests), + Terminal(origin, TOKEN) as terminal, + ): + open_ready(terminal) + terminal.send("\r") + terminal.wait(SECRET) + assert termios.tcgetattr(terminal.slave) != terminal.original + assert b"\x1b[?25l" in terminal.raw + assert b"\x1b[?1049h" in terminal.raw + before = len(requests) + terminal.process.send_signal(signal.SIGTERM) + terminal.wait_exit(expected=0) + assert len(requests) == before + assert TOKEN.encode() not in terminal.raw + + def test_posix_terminal_observation_rejects_actual_unrestored_raw_mode(): + """The restoration observer must not repair a genuinely raw private PTY.""" + import termios + import tty + + with ( + socket_peer(Reads()) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + terminal.wait("Terminal project") + terminal.quit() + # Deliberate test-only fault after normal CLI teardown; the helper + # must only observe it. Closing this private PTY needs no restore. + tty.setraw(terminal.slave) + injected = termios.tcgetattr(terminal.slave) + assert injected != terminal.original + assert not injected[3] & (termios.ICANON | termios.ECHO) + with pytest.raises( + AssertionError, match="terminal settings were not restored" + ): + terminal._assert_restored() + assert termios.tcgetattr(terminal.slave) == injected + assert termios.tcgetattr(terminal.slave) != terminal.original + + +def test_manual_handoffs_name_existing_commands_without_invoking_them(cli): + with socket_peer(Reads()) as (origin, requests): + for args in ( + ("task", "show"), + ("task", "context"), + ("task", "requirements"), + ("me", "assignments"), + ("me", "submissions"), + ("submission", "show"), + ("submission", "findings"), + ("contributions",), + ): + result = cli(origin, TOKEN, *args, "--help") + assert result.returncode == 0 and result.stderr == "" + assert "Usage:" in result.stdout + assert requests == [] + + +def test_redirected_input_output_and_json_are_rejected_without_network(cli): + with socket_peer(Reads()) as (origin, requests): + result = cli(origin, TOKEN, "tui") + assert result.returncode == 2 and "\x1b" not in result.stdout + result.stderr + result = cli(origin, TOKEN, "tui", "--output", "json") + assert result.returncode == 2 and result.stdout == "" + assert "invalid_output" in result.stderr + if os.name == "posix": + import pty + import termios + + master, slave = pty.openpty() + original = termios.tcgetattr(slave) + try: + for redirect_input in (False, True): + result = subprocess.run( # noqa: S603 - built binary, fixed argv + [executable(), "tui"], + stdin=subprocess.DEVNULL if redirect_input else slave, + stdout=slave if redirect_input else subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment(origin, TOKEN), + timeout=10, + check=False, + ) + assert result.returncode == 2 + assert termios.tcgetattr(slave) == original + finally: + os.close(master) + os.close(slave) + else: + for redirect in ("input", "output"): + with Terminal(origin, TOKEN, redirect=redirect) as terminal: + terminal.wait_exit(expected=2) + terminal.assert_no_terminal_controls() + assert requests == [] diff --git a/cli/tests/integration/test_tui_native_proof.py b/cli/tests/integration/test_tui_native_proof.py new file mode 100644 index 000000000..9655aa18e --- /dev/null +++ b/cli/tests/integration/test_tui_native_proof.py @@ -0,0 +1,255 @@ +"""Falsification tests for native proof selection, outcomes and receipt custody.""" + +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +import tui_native_proof +from tui_native_proof import ProofError, Reports, run_tests, validate, verify + + +BINDING = { + "source": "a" * 40, + "platform": "linux-amd64", + "run_id": "1", + "run_attempt": "2", + "suite": "cli/tests/integration/test_tui_http.py", +} + + +def passing(): + return { + "binding": BINDING, + "finished": True, + "exit_code": 0, + "collected": ["test_suite.py::test_a", "test_suite.py::test_b"], + "selected": ["test_suite.py::test_a", "test_suite.py::test_b"], + "deselected": [], + "collection_errors": [], + "reports": [ + { + "nodeid": node, + "when": phase, + "outcome": "passed", + "has_wasxfail": False, + "wasxfail": None, + } + for node in ["test_suite.py::test_a", "test_suite.py::test_b"] + for phase in ["setup", "call", "teardown"] + ], + } + + +def test_valid_control(): + assert validate(passing(), BINDING) == 2 + + +@pytest.mark.parametrize( + "defect", + [ + "omitted_test", + "duplicate_node", + "empty", + "deselected", + "selected_omission", + "duplicate_report", + "uncollected_report", + "skip", + "xfail", + "xpass", + "failure", + "collection_error", + "exit_failure", + "interrupted", + "binding", + "missing_teardown", + "phase_order", + ], +) +def test_corrupt_evidence_fails_closed(defect): + proof = deepcopy(passing()) + if defect == "omitted_test": + proof["reports"] = proof["reports"][:3] + elif defect == "duplicate_node": + proof["collected"].append(proof["collected"][0]) + elif defect == "empty": + proof.update(collected=[], selected=[], reports=[]) + elif defect == "deselected": + proof["deselected"] = [proof["collected"][0]] + elif defect == "selected_omission": + proof["selected"].pop() + elif defect == "duplicate_report": + proof["reports"].append(proof["reports"][0]) + elif defect == "uncollected_report": + proof["reports"][0]["nodeid"] = "test_other.py::test_unknown" + elif defect in {"skip", "xfail", "xpass", "failure"}: + call = proof["reports"][1] + call["outcome"] = { + "skip": "skipped", + "xfail": "skipped", + "xpass": "passed", + "failure": "failed", + }[defect] + if defect in {"xfail", "xpass"}: + call.update(has_wasxfail=True, wasxfail="") + elif defect == "collection_error": + proof["collection_errors"] = [{"nodeid": "broken.py", "outcome": "failed"}] + elif defect == "exit_failure": + proof["exit_code"] = 1 + elif defect == "interrupted": + proof["finished"] = False + elif defect == "binding": + proof["binding"]["source"] = "b" * 40 + elif defect == "missing_teardown": + proof["reports"].pop() + elif defect == "phase_order": + proof["reports"][0]["when"] = "call" + with pytest.raises(ProofError): + validate(proof, BINDING) + + +@pytest.mark.parametrize( + ("body", "accepted"), + [ + ("def test_ok(): assert True", True), + ("def test_bad(): assert False", False), + ("import pytest\ndef test_skip(): pytest.skip('')", False), + ("import pytest\n@pytest.mark.xfail\ndef test_xfail(): assert False", False), + ( + "import pytest\n@pytest.mark.xfail(strict=False)\ndef test_xpass(): pass", + False, + ), + ("def test_interrupt(): raise KeyboardInterrupt", False), + ("# no tests", False), + ("raise RuntimeError('collection failure')", False), + ], +) +def test_real_pytest_outcomes(tmp_path, monkeypatch, body, accepted): + monkeypatch.setenv("PYTEST_ADDOPTS", "--collect-only -k nonexistent") + monkeypatch.setenv("PYTEST_PLUGINS", "nonexistent_plugin") + suite = tmp_path / f"test_{tmp_path.name}.py" + suite.write_text(body + "\n", encoding="utf-8") + output = tmp_path / "proof" + if accepted: + run_tests(suite, BINDING, output) + assert verify(output, BINDING) == 1 + else: + with pytest.raises(ProofError): + run_tests(suite, BINDING, output) + assert not (output / "completion.json").exists() + evidence = json.loads((output / "reports.json").read_text()) + assert evidence["binding"] == BINDING + if "test_xfail" in body or "test_xpass" in body: + assert any(report["has_wasxfail"] for report in evidence["reports"]) + if "test_xpass" in body: + assert evidence["exit_code"] == 0 # Non-strict XPASS evades exit/JUnit totals. + + +@pytest.mark.parametrize("selection", ["deselect", "silent_omit", "duplicate"]) +def test_real_collection_modification(tmp_path, selection): + suite = tmp_path / f"test_{tmp_path.name}.py" + suite.write_text("def test_a(): pass\ndef test_b(): pass\n", encoding="utf-8") + + class Selection: + def pytest_collection_modifyitems(self, config, items): + if selection == "deselect": + config.hook.pytest_deselected(items=[items.pop()]) + elif selection == "silent_omit": + items.pop() + else: + items.append(items[0]) + + with pytest.raises(ProofError): + run_tests(suite, BINDING, tmp_path / "proof", plugins=[Selection()]) + assert not (tmp_path / "proof/completion.json").exists() + evidence = json.loads((tmp_path / "proof/reports.json").read_text()) + assert evidence["exit_code"] == 0 and not evidence["collection_errors"] + assert len(evidence["collected"]) == 2 + assert len(evidence["selected"]) == (3 if selection == "duplicate" else 1) + assert bool(evidence["deselected"]) == (selection == "deselect") + + +def test_receipt_rejects_stale_binding_tamper_and_failed_retry(tmp_path): + suite = tmp_path / f"test_{tmp_path.name}.py" + suite.write_text("def test_ok(): pass\n", encoding="utf-8") + output = tmp_path / "proof" + run_tests(suite, BINDING, output) + for key in ["source", "platform", "run_id", "run_attempt"]: + changed = BINDING | {key: "different"} + with pytest.raises(ProofError): + verify(output, changed) + with (output / "reports.json").open("ab") as handle: + handle.write(b" ") + with pytest.raises(ProofError): + verify(output, BINDING) + run_tests(suite, BINDING, output) + with pytest.raises(ProofError): + run_tests(suite, BINDING, output, extra_args=["--collect-only"]) + assert not (output / "completion.json").exists() + + +def test_abort_before_pytest_return_never_completes(tmp_path, monkeypatch): + output = tmp_path / "proof" + output.mkdir() + (output / "completion.json").write_text("stale", encoding="utf-8") + + def abort(*args, **kwargs): + raise KeyboardInterrupt + + monkeypatch.setattr(pytest, "main", abort) + with pytest.raises(KeyboardInterrupt): + run_tests(Path("test_suite.py"), BINDING, output) + assert not (output / "completion.json").exists() + assert json.loads((output / "reports.json").read_text())["finished"] is False + + +def test_wasxfail_presence_is_not_lost(): + class Report: + nodeid, when, outcome, wasxfail = "test.py::test_a", "call", "passed", "" + + observer = Reports() + observer.pytest_runtest_logreport(Report()) + assert observer.reports[0]["has_wasxfail"] is True + assert observer.reports[0]["wasxfail"] == "" + + +@pytest.mark.parametrize( + ("error", "detail"), + [ + (ProofError("receipt count differs"), "ProofError: receipt count differs"), + (OSError("credential_canary_private_path"), "OSError"), + (ValueError("credential_canary_private_payload"), "ValueError"), + ], +) +def test_main_rejection_discloses_only_fixed_proof_reason( + tmp_path, monkeypatch, capsys, error, detail +): + monkeypatch.setattr( + tui_native_proof.sys, + "argv", + [ + "tui_native_proof.py", + "verify", + "--output", + str(tmp_path), + "--source", + BINDING["source"], + "--platform", + BINDING["platform"], + "--run-id", + BINDING["run_id"], + "--run-attempt", + BINDING["run_attempt"], + ], + ) + + def reject(*args): + raise error + + monkeypatch.setattr(tui_native_proof, "verify", reject) + assert tui_native_proof.main() == 1 + output = capsys.readouterr() + assert output.out == "" + assert output.err == f"native terminal proof rejected: {detail}\n" diff --git a/cli/tests/integration/test_tui_public_api.py b/cli/tests/integration/test_tui_public_api.py new file mode 100644 index 000000000..e6a472d01 --- /dev/null +++ b/cli/tests/integration/test_tui_public_api.py @@ -0,0 +1,221 @@ +"""Built native PTY/ConPTY -> real socket/Flow verifier -> isolated PostgreSQL. + +Canonical fixtures arrange an approved guide, started work and a retained +immutable attempt. This is read/navigation/reauthorization proof, not a public +intake or external artifact-provider journey. No AUTH/TASK read port is replaced; +only the signed fixture issuer's JWKS transport is local. +""" + +import asyncio +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +import pytest_asyncio +from sqlalchemy import func, select + +from test_public_self_service import ( + clean_postgres_database as clean_postgres_database, + postgres_database_url as postgres_database_url, +) +from test_submission_public_api import socket_api +from tui_terminal_support import Terminal +from app.api.deps.authorization import compose_hidden_submission_creation_command +from app.core.identifiers import new_record_id +from app.modules.authorization.models import ProjectRoleGrant +from app.modules.checkers.models import CheckerRun +from app.modules.contributions.records.models import ContributionRecord +from app.modules.tasks.models import Submission, TaskAssignment, WorkstreamTask +from tests.authentication.fixtures import ( + auth_database_env as _auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authentication.support import issue_asymmetric_token +from tests.authorization.admin_access.fixtures import ( + admin_access as _admin_access, + signed_access as _signed_access, +) +from tests.authorization.admin_access.support import SignedActor +from tests.authorization.task_authority.test_postgresql import grant, project_manager +from tests.authorization.task_reads.support import create_task +from tests.conftest import ( + pagination_cursor_hmac_secret as pagination_cursor_hmac_secret, +) +from tests.tasks.test_submission_lineage import _prepared_packet + +signed_access = pytest_asyncio.fixture(_signed_access.__wrapped__) +admin_access = pytest_asyncio.fixture(_admin_access.__wrapped__) +auth_database_env = _auth_database_env + + +@pytest.mark.asyncio +async def test_tui_real_signed_owner_positive_reads_then_fresh_grant_revocation( + tmp_path, auth_database_env, admin_access +): + signed = admin_access.signed + async with _prepared_packet(auth_database_env, tmp_path) as h: + # Bind the actual stored identity link, not intake_client's actor override. + subject = f"human-{h.context.actor_profile_id}" + owner = SignedActor( + h.context.actor_profile_id, + subject, + issue_asymmetric_token( + signed.private_key, + claims={"sub": subject, "jti": str(uuid4()), "roles": []}, + ), + ) + client = signed.client + profile = await client.get("/api/v1/actors/me", headers=owner.headers) + assert profile.status_code == 200, profile.text + assert profile.json()["actor_profile_id"] == str(owner.id) + async with h.factory() as session: + receipt = await compose_hidden_submission_creation_command( + session, + h.context, + request_id=new_record_id(), + correlation_id=new_record_id(), + ).create(h.creation) + submission_id = str(receipt.submission_id) + access = SimpleNamespace(signed=signed) + ready_id = await create_task(access, h.project_id, owner) + project, task = str(h.project_id), str(h.task_id) + paths = { + "projects": "/api/v1/actors/me/projects?limit=50", + "ready": f"/api/v1/projects/{project}/tasks/ready?limit=50", + "mine": f"/api/v1/actors/me/assignments?project_id={project}&state=active&limit=50", + "attempts": f"/api/v1/actors/me/submissions?project_id={project}&limit=50", + "task": f"/api/v1/tasks/{task}", + "ready_task": f"/api/v1/tasks/{ready_id}", + "context": f"/api/v1/tasks/{task}/work-context", + "intake": f"/api/v1/tasks/{task}/submission-requirements", + "submission": f"/api/v1/submissions/{submission_id}", + } + direct = {} + for name, path in paths.items(): + response = await client.get(path, headers=owner.headers) + assert response.status_code == 200, (name, response.text) + direct[name] = response.json() + assert [item["project_id"] for item in direct["projects"]["items"]] == [project] + assert [item["task_id"] for item in direct["ready"]["items"]] == [str(ready_id)] + assert [item["task_id"] for item in direct["mine"]["items"]] == [task] + assert [item["submission_id"] for item in direct["attempts"]["items"]] == [ + submission_id + ] + assert direct["submission"]["task_id"] == task + assert ( + direct["context"]["task"]["task_id"] == direct["intake"]["task_id"] == task + ) + assert ( + direct["context"]["guide"]["version"] == direct["intake"]["guide_version"] + ) + assert ( + direct["ready"]["items"][0]["compensation"] + == direct["ready_task"]["compensation"] + ) + # A different signed, genuinely granted actor still cannot inspect an + # immutable attempt or reuse its assignment-sensitive guide context. + foreign = await signed.actor("tui-foreign-owner", roles=()) + await grant(SimpleNamespace(signed=signed, target=foreign), owner, h.project_id) + for name in ("submission", "context"): + response = await client.get(paths[name], headers=foreign.headers) + assert response.status_code == 404, (name, response.text) + # The real role-grant owner forbids self revocation. Use a distinct + # signed manager whose exact project grant was issued by the actual + # bootstrapped administrator, rather than bypassing that guard. + manager = await project_manager(admin_access, h.project_id) + + models = ( + WorkstreamTask, + TaskAssignment, + Submission, + CheckerRun, + ContributionRecord, + ) + + async def counts(): + async with h.factory() as session: + return { + model.__tablename__: await session.scalar( + select(func.count()).select_from(model) + ) + for model in models + } + + before = await counts() + requests = [] + app = client._transport.app + assert not app.dependency_overrides, ( + "public proof cannot replace AUTH/TASK owners" + ) + + async def observed(scope, receive, send): + if scope["type"] == "http": + requests.append((scope["method"], scope["path"], scope["query_string"])) + await app(scope, receive, send) + + async with socket_api(observed) as origin: + with Terminal(origin, owner.token, "--plain") as terminal: + + async def wait(text): + return await asyncio.to_thread(terminal.wait, text) + + await wait(direct["projects"]["items"][0]["name"]) + terminal.send("\r") + await wait(direct["ready"]["items"][0]["title"]) + terminal.send("\r") + await wait(str(ready_id)) + await wait(direct["ready_task"]["description"]) + terminal.send("\x1b") + await wait("READY") + terminal.send("\t") + await wait("MINE") + await wait(direct["mine"]["items"][0]["title"]) + terminal.send("\r") + await wait(task) + await wait(direct["task"]["description"]) + terminal.send("\t") + await wait("CONTEXT") + await wait(direct["context"]["guide"]["version"]) + terminal.send("\t") + await wait("INTAKE") + terminal.send("\x1b") + await wait("MINE") + terminal.send("\t") + await wait("SUBMISSIONS") + await wait(direct["attempts"]["items"][0]["title"]) + terminal.send("\r") + await wait(submission_id) + await wait(direct["submission"]["summary"]) + async with h.factory() as session: + grant_id = await session.scalar( + select(ProjectRoleGrant.id).where( + ProjectRoleGrant.actor_profile_id == str(owner.id), + ProjectRoleGrant.project_id == project, + ProjectRoleGrant.role == "submitter", + ProjectRoleGrant.status == "active", + ) + ) + assert grant_id is not None + revoked = await client.post( + f"/api/v1/projects/{project}/role-grants/{grant_id}/revoke", + headers=manager.headers | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Prove TUI observes fresh grant revocation"}, + ) + assert revoked.status_code == 200, revoked.text + denied = await client.get(paths["submission"], headers=owner.headers) + assert denied.status_code == 404 + terminal.send("r") + await wait("Denied") + assert direct["submission"]["summary"] not in terminal.screen + assert submission_id not in terminal.screen + await asyncio.to_thread(terminal.quit) + assert owner.token.encode() not in terminal.raw + allowed = {path.split("?", 1)[0] for path in paths.values()} | { + "/api/v1/actors/me" + } + assert requests and all( + method == "GET" and path in allowed for method, path, _ in requests + ) + assert ("GET", paths["submission"], b"") in requests + assert await counts() == before, "read-only TUI mutated product records" diff --git a/cli/tests/integration/tui_native_proof.py b/cli/tests/integration/tui_native_proof.py new file mode 100644 index 000000000..93e72a5f0 --- /dev/null +++ b/cli/tests/integration/tui_native_proof.py @@ -0,0 +1,215 @@ +"""Exact-selection pytest evidence for native terminal proof, not release assets.""" + +from __future__ import annotations + +import argparse +from collections import Counter +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import subprocess +import sys + + +SUITE = "cli/tests/integration/test_tui_http.py" +TARGETS = { + ("Linux", "x86_64"): "linux-amd64", + ("Darwin", "x86_64"): "darwin-amd64", + ("Darwin", "arm64"): "darwin-arm64", + ("Windows", "AMD64"): "windows-amd64", +} + + +class ProofError(ValueError): + """Incomplete, mismatched or unsuccessful native proof.""" + + +class Reports: + """Observe collection before selection and retain every phase, including XPASS.""" + + def __init__(self): + self.collected = [] + self.selected = [] + self.deselected = [] + self.collection_errors = [] + self.reports = [] + + def pytest_itemcollected(self, item): + self.collected.append(item.nodeid) + + def pytest_deselected(self, items): + self.deselected.extend(item.nodeid for item in items) + + def pytest_collection_finish(self, session): + self.selected = [item.nodeid for item in session.items] + + def pytest_collectreport(self, report): + if report.outcome != "passed": + self.collection_errors.append( + {"nodeid": report.nodeid, "outcome": report.outcome} + ) + + def pytest_runtest_logreport(self, report): + self.reports.append( + { + "nodeid": report.nodeid, + "when": report.when, + "outcome": report.outcome, + # Presence, not truthiness: an empty xfail reason is still xfail. + "wasxfail": getattr(report, "wasxfail", None), + "has_wasxfail": hasattr(report, "wasxfail"), + } + ) + + +def validate(evidence, binding): + if evidence["binding"] != binding or evidence["finished"] is not True: + raise ProofError("unfinished or mismatched proof") + if type(evidence["exit_code"]) is not int or evidence["exit_code"] != 0: + raise ProofError("pytest did not exit successfully") + collected = evidence["collected"] + if not collected or any(not isinstance(node, str) for node in collected): + raise ProofError("empty or invalid collection") + if len(set(collected)) != len(collected): + raise ProofError("duplicate collected node IDs") + if evidence["deselected"] or evidence["collection_errors"]: + raise ProofError("deselection or collection failure") + if Counter(evidence["selected"]) != Counter(collected): + raise ProofError("selected tests differ from raw collection") + reports = {node: [] for node in collected} + for report in evidence["reports"]: + node = report["nodeid"] + if node not in reports: + raise ProofError("report for uncollected test") + if ( + report["outcome"] != "passed" + or report["has_wasxfail"] is not False + or report["wasxfail"] is not None + ): + raise ProofError("unsuccessful or xfail phase") + reports[node].append(report["when"]) + if any(phases != ["setup", "call", "teardown"] for phases in reports.values()): + raise ProofError("missing, duplicate or out-of-order phase reports") + return len(collected) + + +def run_tests(test_file, binding, output, *, extra_args=(), plugins=()): + """Internal seam for adversarial self-tests; CLI always selects SUITE exactly.""" + output.mkdir(parents=True, exist_ok=True) + receipt = output / "completion.json" + receipt.unlink(missing_ok=True) # A failed retry cannot inherit prior success. + os.environ.pop("PYTEST_ADDOPTS", None) + os.environ.pop("PYTEST_PLUGINS", None) + os.environ["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + import pytest + + observer = Reports() + evidence = {"binding": binding, "finished": False, "exit_code": None} + try: + code = pytest.main( + [ + "-c", + os.devnull, + "--rootdir", + str(test_file.parent), + "--confcutdir", + str(test_file.parent), + "-p", + "no:cacheprovider", + "-o", + "addopts=", + "-q", + str(test_file), + *extra_args, + ], + plugins=[observer, *plugins], + ) + evidence.update(finished=True, exit_code=int(code)) + finally: + evidence.update(vars(observer)) + raw = json.dumps(evidence, indent=2, sort_keys=True).encode() + b"\n" + (output / "reports.json").write_bytes(raw) + count = validate(evidence, binding) + receipt.write_text( + json.dumps( + { + "binding": binding, + "reports_sha256": hashlib.sha256(raw).hexdigest(), + "passed": count, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + +def verify(output, binding): + receipt = json.loads((output / "completion.json").read_text(encoding="utf-8")) + raw = (output / "reports.json").read_bytes() + if receipt["binding"] != binding: + raise ProofError("receipt binding differs") + if receipt["reports_sha256"] != hashlib.sha256(raw).hexdigest(): + raise ProofError("report bytes differ from completion receipt") + count = validate(json.loads(raw), binding) + if type(receipt["passed"]) is not int or receipt["passed"] != count: + raise ProofError("receipt count differs") + return count + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=["run", "verify"]) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--source", required=True) + parser.add_argument("--platform", required=True, choices=sorted(TARGETS.values())) + parser.add_argument("--run-id", required=True) + parser.add_argument("--run-attempt", required=True) + args = parser.parse_args() + try: + if not re.fullmatch(r"[0-9a-f]{40}", args.source): + raise ProofError("full source SHA required") + if not all( + re.fullmatch(r"[1-9][0-9]*", value) + for value in [args.run_id, args.run_attempt] + ): + raise ProofError("positive workflow run and attempt required") + binding = { + "source": args.source, + "platform": args.platform, + "run_id": args.run_id, + "run_attempt": args.run_attempt, + "suite": SUITE, + } + if args.operation == "run": + root = Path(__file__).resolve().parents[3] + actual = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=root, text=True + ).strip() + if actual != args.source: + raise ProofError("checkout source differs") + if subprocess.check_output( + ["git", "status", "--porcelain"], cwd=root, text=True + ).strip(): + raise ProofError("native proof requires an unchanged checkout") + if TARGETS.get((platform.system(), platform.machine())) != args.platform: + raise ProofError("runner is not the claimed native target") + run_tests(root / SUITE, binding, args.output) + count = verify(args.output, binding) + print(f"Validated native terminal proof: {count} tests; {args.platform}") + except (ProofError, OSError, ValueError, KeyError, TypeError) as exc: + detail = f": {exc}" if isinstance(exc, ProofError) else "" + print( + f"native terminal proof rejected: {type(exc).__name__}{detail}", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cli/tests/integration/tui_terminal_support.py b/cli/tests/integration/tui_terminal_support.py new file mode 100644 index 000000000..153a8aa64 --- /dev/null +++ b/cli/tests/integration/tui_terminal_support.py @@ -0,0 +1,876 @@ +"""Bounded native PTY/ConPTY process driver; no application/model imports.""" + +from __future__ import annotations + +from contextlib import contextmanager +import codecs +import ctypes as ct +import errno +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +from queue import Empty, Full, Queue +import re +import select +import signal +import struct +import subprocess +import sys +import tempfile +from threading import Event, Thread +import time +import unicodedata + +if os.name == "posix": + import fcntl + import pty + import termios + + +def executable(): + binary = os.environ.get("WORKSTREAM_CLI_EXECUTABLE") + assert binary and Path(binary).is_file(), "build and set WORKSTREAM_CLI_EXECUTABLE" + return binary + + +def environment(origin, token, extra=None): + env = { + key: value + for key, value in os.environ.items() + if key.upper() in {"PATH", "LANG", "SYSTEMROOT", "WINDIR", "TEMP", "TMP"} + } + env.update( + WORKSTREAM_API_URL=origin, + WORKSTREAM_TOKEN=token, + TERM="xterm-256color", + ) + env.update(extra or {}) + return env + + +class Terminal: + """A real child on a PTY with a bounded, minimal VT screen observer. + + The screen observer only handles renderer movement/erase/SGR, never executes + OSC. Raw output is retained too, so hostile terminal controls cannot hide + behind a screen parser that ignores them. + """ + + def __init__(self, origin, token, *args, columns=120, rows=40, extra_env=None): + assert os.name == "posix", "unsupported native terminal host" + self.master, self.slave = pty.openpty() + self.original = termios.tcgetattr(self.slave) + self._screen_setup(columns, rows) + self.resize(columns, rows, notify=False) + self.process = subprocess.Popen( # noqa: S603 - built binary, fixed test argv + [executable(), "tui", *args], + stdin=self.slave, + stdout=self.slave, + stderr=self.slave, + env=environment(origin, token, extra_env), + start_new_session=True, + ) + + def _screen_setup(self, columns, rows): + self.columns, self.rows = columns, rows + self.raw = bytearray() + self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + self.pending = "" + self.sent_after = None + self.x = self.y = 0 + self.cells = [[" "] * columns for _ in range(rows)] + + def _write(self, data): + os.write(self.master, data) + + def resize(self, columns, rows, *, notify=True): + fcntl.ioctl( + self.slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0) + ) + self.columns, self.rows = columns, rows + if notify: + self.cells = [[" "] * columns for _ in range(rows)] + self.x = self.y = 0 + self.process.send_signal(signal.SIGWINCH) + + @property + def screen(self): + return "\n".join("".join(row).rstrip() for row in self.cells) + + def _render(self, text): + self.pending += text + while self.pending: + if self.pending.startswith("\x1b"): + match = re.match(r"\x1b\[([0-?]*)([ -/]*)([@-~])", self.pending) + if match: + self.pending = self.pending[match.end() :] + params, _, command = match.groups() + values = [ + int(part) if part.isdecimal() else 0 + for part in params.lstrip("?<>=!").split(";") + ] + n = values[0] or 1 + if command in {"H", "f"}: + self.y = min(self.rows - 1, n - 1) + self.x = min( + self.columns - 1, + (values[1] or 1) - 1 if len(values) > 1 else 0, + ) + elif command == "A": + self.y = max(0, self.y - n) + elif command == "B": + self.y = min(self.rows - 1, self.y + n) + elif command == "d": + self.y = min(self.rows - 1, n - 1) + elif command in {"E", "F"}: + self.y = ( + min(self.rows - 1, self.y + n) + if command == "E" + else max(0, self.y - n) + ) + self.x = 0 + elif command == "C": + self.x = min(self.columns - 1, self.x + n) + elif command == "D": + self.x = max(0, self.x - n) + elif command == "G": + self.x = min(self.columns - 1, n - 1) + elif command == "J": + if values[0] in {2, 3}: + self.cells = [ + [" "] * self.columns for _ in range(self.rows) + ] + else: + self.cells[self.y][self.x :] = [" "] * ( + self.columns - self.x + ) + for row in range(self.y + 1, self.rows): + self.cells[row] = [" "] * self.columns + elif command == "K": + start, end = ( + (0, self.columns) + if values[0] == 2 + else (self.x, self.columns) + ) + self.cells[self.y][start:end] = [" "] * (end - start) + elif command == "n" and params == "6": + self._write(b"\x1b[1;1R") + continue + if self.pending.startswith("\x1b]"): + match = re.match(r"\x1b\].*?(?:\x07|\x1b\\)", self.pending, re.S) + if not match: + return + sequence = match.group() + self.pending = self.pending[match.end() :] + if sequence.startswith("\x1b]11;?"): + self._write(b"\x1b]11;rgb:0000/0000/0000\x1b\\") + elif sequence.startswith("\x1b]10;?"): + self._write(b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\") + continue + if len(self.pending) < 3 or self.pending.startswith("\x1b["): + return + self.pending = self.pending[2:] + continue + character, self.pending = self.pending[0], self.pending[1:] + if character == "\r": + self.x = 0 + elif character == "\t": + self.x = min(self.columns - 1, (self.x // 8 + 1) * 8) + elif character == "\n": + self.y += 1 + if self.y == self.rows: + self.cells.pop(0) + self.cells.append([" "] * self.columns) + self.y -= 1 + elif character == "\b": + self.x = max(0, self.x - 1) + elif character >= " " and not unicodedata.combining(character): + if self.x >= self.columns: + self.x = 0 + self.y = min(self.rows - 1, self.y + 1) + self.cells[self.y][self.x] = character + self.x += ( + 2 if unicodedata.east_asian_width(character) in {"W", "F"} else 1 + ) + + def pump(self, seconds=0.1): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + ready, _, _ = select.select( + [self.master], [], [], max(0, deadline - time.monotonic()) + ) + if not ready: + break + try: + data = os.read(self.master, 65536) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not data: + break + self.raw.extend(data) + assert len(self.raw) < 4 * 1024 * 1024, "unbounded terminal output" + self._render(self.decoder.decode(data)) + + def wait(self, text, timeout=10): + deadline = time.monotonic() + timeout + while ( + text not in self.screen + or (self.sent_after is not None and len(self.raw) <= self.sent_after) + ) and time.monotonic() < deadline: + self.pump() + returncode = self.process.poll() + assert returncode is None, ( + f"TUI exited (return code {returncode}): {self.screen}" + ) + assert text in self.screen, f"missing {text!r} on screen:\n{self.screen}" + assert self.sent_after is None or len(self.raw) > self.sent_after, ( + "no new terminal frame after input" + ) + self.sent_after = None + return self.screen + + def send(self, keys): + # Observe output already queued before this input. Otherwise an expect + # could mistake the previous project/detail's frame for the new result. + self.pump(0.02) + self.sent_after = len(self.raw) + self._write(keys.encode() if isinstance(keys, str) else keys) + + def quit(self, key="q"): + self.send(key) + self.wait_exit() + + def wait_exit(self, expected=0): + deadline = time.monotonic() + 10 + while self.process.poll() is None and time.monotonic() < deadline: + self.pump() + assert self.process.poll() == expected, f"TUI exit mismatch: {self.screen}" + self.pump() + self._assert_restored() + + def _assert_restored(self): + if sys.platform == "darwin": + # XNU sets transient PENDIN on raw -> canonical restoration. Its + # ttyselect(FREAD) -> ttnread -> ttypend path settles that kernel + # state without consuming input or changing application flags. + # See xnu-11417.140.69/bsd/kern/tty.c in apple-oss-distributions/xnu. + select.select([self.slave], [], [], 0) + restored = termios.tcgetattr(self.slave) + # Only bounded OS terminal fields reach this diagnostic, never output, + # credentials, or server text. Keep the exact restoration comparison. + assert restored == self.original, ( + "terminal settings were not restored; " + f"before_flags_speeds={self.original[:6]}; " + f"after_flags_speeds={restored[:6]}; " + f"before_cc={[v if isinstance(v, int) else ord(v) for v in self.original[6]]}; " + f"after_cc={[v if isinstance(v, int) else ord(v) for v in restored[6]]}; " + f"before_int_cc={[i for i, v in enumerate(self.original[6]) if isinstance(v, int)]}; " + f"after_int_cc={[i for i, v in enumerate(restored[6]) if isinstance(v, int)]}" + ) + self._assert_cursor_and_screen_restored() + + def _assert_cursor_and_screen_restored(self): + for enabled, disabled in ( + (b"\x1b[?25l", b"\x1b[?25h"), + (b"\x1b[?1049h", b"\x1b[?1049l"), + ): + if enabled in self.raw: + assert self.raw.rfind(disabled) > self.raw.rfind(enabled), ( + "terminal mode left active" + ) + + def assert_no_terminal_controls(self): + assert b"\x1b" not in self.raw + + def close(self): + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=5) + os.close(self.master) + os.close(self.slave) + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + +if os.name == "nt": + from ctypes import wintypes as wt + + class _Coord(ct.Structure): + _fields_ = [("X", ct.c_short), ("Y", ct.c_short)] + + class _StartupInfo(ct.Structure): + _fields_ = [ + ("cb", wt.DWORD), + ("lpReserved", wt.LPWSTR), + ("lpDesktop", wt.LPWSTR), + ("lpTitle", wt.LPWSTR), + ("dwX", wt.DWORD), + ("dwY", wt.DWORD), + ("dwXSize", wt.DWORD), + ("dwYSize", wt.DWORD), + ("dwXCountChars", wt.DWORD), + ("dwYCountChars", wt.DWORD), + ("dwFillAttribute", wt.DWORD), + ("dwFlags", wt.DWORD), + ("wShowWindow", wt.WORD), + ("cbReserved2", wt.WORD), + ("lpReserved2", ct.POINTER(wt.BYTE)), + ("hStdInput", wt.HANDLE), + ("hStdOutput", wt.HANDLE), + ("hStdError", wt.HANDLE), + ] + + class _StartupInfoEx(ct.Structure): + _fields_ = [("StartupInfo", _StartupInfo), ("lpAttributeList", ct.c_void_p)] + + class _ProcessInfo(ct.Structure): + _fields_ = [ + ("hProcess", wt.HANDLE), + ("hThread", wt.HANDLE), + ("dwProcessId", wt.DWORD), + ("dwThreadId", wt.DWORD), + ] + + class _BasicJobLimits(ct.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ct.c_int64), + ("PerJobUserTimeLimit", ct.c_int64), + ("LimitFlags", wt.DWORD), + ("MinimumWorkingSetSize", ct.c_size_t), + ("MaximumWorkingSetSize", ct.c_size_t), + ("ActiveProcessLimit", wt.DWORD), + ("Affinity", ct.c_size_t), + ("PriorityClass", wt.DWORD), + ("SchedulingClass", wt.DWORD), + ] + + class _IoCounters(ct.Structure): + _fields_ = [ + (name, ct.c_uint64) + for name in ( + "ReadOperationCount", + "WriteOperationCount", + "OtherOperationCount", + "ReadTransferCount", + "WriteTransferCount", + "OtherTransferCount", + ) + ] + + class _JobLimits(ct.Structure): + _fields_ = [ + ("BasicLimitInformation", _BasicJobLimits), + ("IoInfo", _IoCounters), + ("ProcessMemoryLimit", ct.c_size_t), + ("JobMemoryLimit", ct.c_size_t), + ("PeakProcessMemoryUsed", ct.c_size_t), + ("PeakJobMemoryUsed", ct.c_size_t), + ] + + class _CursorInfo(ct.Structure): + _fields_ = [("dwSize", wt.DWORD), ("bVisible", wt.BOOL)] + + class _WindowsAPI: + """Explicit signatures: default ctypes int results truncate x64 handles.""" + + def __init__(self): + self.dll = ct.WinDLL("kernel32", use_last_error=True) + pointer = ct.c_void_p + signatures = { + "CreatePipe": ( + wt.BOOL, + [ct.POINTER(wt.HANDLE), ct.POINTER(wt.HANDLE), pointer, wt.DWORD], + ), + "CloseHandle": (wt.BOOL, [wt.HANDLE]), + "CreatePseudoConsole": ( + ct.c_long, + [_Coord, wt.HANDLE, wt.HANDLE, wt.DWORD, ct.POINTER(wt.HANDLE)], + ), + "ResizePseudoConsole": (ct.c_long, [wt.HANDLE, _Coord]), + "ClosePseudoConsole": (None, [wt.HANDLE]), + "InitializeProcThreadAttributeList": ( + wt.BOOL, + [pointer, wt.DWORD, wt.DWORD, ct.POINTER(ct.c_size_t)], + ), + "UpdateProcThreadAttribute": ( + wt.BOOL, + [ + pointer, + wt.DWORD, + ct.c_size_t, + pointer, + ct.c_size_t, + pointer, + pointer, + ], + ), + "DeleteProcThreadAttributeList": (None, [pointer]), + "CreateProcessW": ( + wt.BOOL, + [ + wt.LPCWSTR, + wt.LPWSTR, + pointer, + pointer, + wt.BOOL, + wt.DWORD, + pointer, + wt.LPCWSTR, + ct.POINTER(_StartupInfoEx), + ct.POINTER(_ProcessInfo), + ], + ), + "CreateJobObjectW": (wt.HANDLE, [pointer, wt.LPCWSTR]), + "SetInformationJobObject": ( + wt.BOOL, + [wt.HANDLE, ct.c_int, pointer, wt.DWORD], + ), + "AssignProcessToJobObject": (wt.BOOL, [wt.HANDLE, wt.HANDLE]), + "TerminateJobObject": (wt.BOOL, [wt.HANDLE, wt.UINT]), + "TerminateProcess": (wt.BOOL, [wt.HANDLE, wt.UINT]), + "ResumeThread": (wt.DWORD, [wt.HANDLE]), + "OpenThread": (wt.HANDLE, [wt.DWORD, wt.BOOL, wt.DWORD]), + "CancelSynchronousIo": (wt.BOOL, [wt.HANDLE]), + "WaitForSingleObject": (wt.DWORD, [wt.HANDLE, wt.DWORD]), + "GetExitCodeProcess": (wt.BOOL, [wt.HANDLE, ct.POINTER(wt.DWORD)]), + "ReadFile": ( + wt.BOOL, + [wt.HANDLE, pointer, wt.DWORD, ct.POINTER(wt.DWORD), pointer], + ), + "WriteFile": ( + wt.BOOL, + [wt.HANDLE, pointer, wt.DWORD, ct.POINTER(wt.DWORD), pointer], + ), + "PeekNamedPipe": ( + wt.BOOL, + [ + wt.HANDLE, + pointer, + wt.DWORD, + pointer, + ct.POINTER(wt.DWORD), + pointer, + ], + ), + "GetConsoleMode": (wt.BOOL, [wt.HANDLE, ct.POINTER(wt.DWORD)]), + "GetConsoleCursorInfo": (wt.BOOL, [wt.HANDLE, ct.POINTER(_CursorInfo)]), + } + for name, (result, arguments) in signatures.items(): + function = getattr( + self.dll, name + ) # Missing ConPTY is a failure, not a skip. + function.restype, function.argtypes = result, arguments + setattr(self, name, function) + + @staticmethod + def check(result): + if not result: + raise ct.WinError(ct.get_last_error()) + return result + + @staticmethod + def hresult(result): + assert result >= 0, f"ConPTY HRESULT 0x{result & 0xFFFFFFFF:08x}" + + class _WindowsProcess: + def __init__(self, api, handle): + self.api, self.handle = api, handle + + def poll(self): + status = self.api.WaitForSingleObject(self.handle, 0) + if status == 258: # WAIT_TIMEOUT; 259 is also a possible real exit code. + return None + assert status == 0, "failed waiting for ConPTY child" + result = wt.DWORD() + self.api.check(self.api.GetExitCodeProcess(self.handle, ct.byref(result))) + return result.value + + class _WindowsTerminal(Terminal): + """Real ConPTY, suspended wrapper, and kill-on-close owned process tree. + + The independent bounded reader stays active during ClosePseudoConsole; + otherwise its final frame can deadlock cleanup. The private receipt is + written inside the child console and contains only modes/cursor/exit. + """ + + def __init__( + self, + origin, + token, + *args, + columns=120, + rows=40, + extra_env=None, + redirect=None, + ): + self.api = api = _WindowsAPI() + self._handles = [] + self._console = wt.HANDLE() + self._attributes = None + self._reader = None + self._writer = None + self._stop = Event() + self._chunks = Queue(maxsize=64) + self._reader_error = None + self._temporary = tempfile.TemporaryDirectory(prefix="workstream-conpty-") + self._receipt = Path(self._temporary.name) / "modes.json" + self.process = None + self._screen_setup(columns, rows) + try: + input_read, self._input = self._pipe() + self._output, output_write = self._pipe() + api.hresult( + api.CreatePseudoConsole( + _Coord(columns, rows), + input_read, + output_write, + 0, + ct.byref(self._console), + ) + ) + self._reader = Thread(target=self._drain, daemon=True) + self._reader.start() + size = ct.c_size_t() + api.InitializeProcThreadAttributeList(None, 1, 0, ct.byref(size)) + assert size.value, "missing ConPTY attribute-list size" + attributes = ct.create_string_buffer(size.value) + api.check( + api.InitializeProcThreadAttributeList( + attributes, 1, 0, ct.byref(size) + ) + ) + self._attributes = attributes + # This attribute takes HPCON itself, not a pointer to HPCON. + api.check( + api.UpdateProcThreadAttribute( + attributes, + 0, + 0x00020016, + self._console, + ct.sizeof(wt.HANDLE), + None, + None, + ) + ) + startup = _StartupInfoEx() + startup.StartupInfo.cb = ct.sizeof(startup) + startup.lpAttributeList = ct.cast(attributes, ct.c_void_p) + info = _ProcessInfo() + argv = [ + sys.executable, + str(Path(__file__).resolve()), + "--conpty-child", + str(self._receipt), + redirect or "none", + executable(), + "tui", + *args, + ] + command = ct.create_unicode_buffer(subprocess.list2cmdline(argv)) + env = environment(origin, token, extra_env) + env_block = ct.create_unicode_buffer( + "\0".join( + f"{key}={value}" + for key, value in sorted( + env.items(), key=lambda item: item[0].upper() + ) + ) + + "\0" + ) + self._job = api.check(api.CreateJobObjectW(None, None)) + self._handles.append(self._job) + limits = _JobLimits() + limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE + api.check( + api.SetInformationJobObject( + self._job, 9, ct.byref(limits), ct.sizeof(limits) + ) + ) + api.check( + api.CreateProcessW( + sys.executable, + command, + None, + None, + False, + 0x00080000 + | 0x00000400 + | 0x00000004, # extended/unicode/suspended + env_block, + None, + ct.byref(startup), + ct.byref(info), + ) + ) + self._handles.extend([info.hProcess, info.hThread]) + self.process = _WindowsProcess(api, info.hProcess) + # The wrapper cannot spawn the CLI before it belongs to our job. + api.check(api.AssignProcessToJobObject(self._job, info.hProcess)) + assert api.ResumeThread(info.hThread) != 0xFFFFFFFF, ( + "cannot resume ConPTY child" + ) + self._close_handle(info.hThread) + self._close_handle(input_read) + self._close_handle(output_write) + api.DeleteProcThreadAttributeList(attributes) + self._attributes = None + except BaseException: + self.close() + raise + + def _pipe(self): + first, second = wt.HANDLE(), wt.HANDLE() + self.api.check( + self.api.CreatePipe(ct.byref(first), ct.byref(second), None, 0) + ) + self._handles.extend([first.value, second.value]) + return first.value, second.value + + def _close_handle(self, handle): + self.api.check(self.api.CloseHandle(handle)) + self._handles.remove(handle) + + def _drain(self): + try: + while not self._stop.is_set(): + available = wt.DWORD() + if not self.api.PeekNamedPipe( + self._output, None, 0, None, ct.byref(available), None + ): + if ct.get_last_error() in { + 109, + 232, + }: # broken/disconnected pipe + return + raise ct.WinError(ct.get_last_error()) + if not available.value: + self._stop.wait(0.005) + continue + buffer = ct.create_string_buffer(min(65536, available.value)) + count = wt.DWORD() + self.api.check( + self.api.ReadFile( + self._output, buffer, len(buffer), ct.byref(count), None + ) + ) + try: + self._chunks.put_nowait(buffer.raw[: count.value]) + except Full: + self._reader_error = "unbounded ConPTY output" + # Keep draining even on failure so teardown cannot deadlock. + except Exception as error: + self._reader_error = f"ConPTY reader failed: {type(error).__name__}" + + def _write(self, data): + assert len(data) <= 4096, "unbounded ConPTY input" + errors = [] + + def write(): + try: + count = wt.DWORD() + self.api.check( + self.api.WriteFile( + self._input, + data, + len(data), + ct.byref(count), + None, + ) + ) + assert count.value == len(data), "short ConPTY input write" + except Exception as error: + errors.append(error) + + # A stalled child must not block the test thread inside WriteFile. + self._writer = Thread(target=write, daemon=True) + self._writer.start() + self._writer.join(timeout=2) + if self._writer.is_alive(): + handle = self.api.check( + self.api.OpenThread(0x0001, False, self._writer.native_id) + ) + try: + self.api.CancelSynchronousIo(handle) + self._writer.join(timeout=2) + finally: + self.api.check(self.api.CloseHandle(handle)) + raise AssertionError("ConPTY input write timed out") + if errors: + raise errors[0] + self._writer = None + + def pump(self, seconds=0.1): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + assert self._reader_error is None, self._reader_error + try: + data = self._chunks.get(timeout=max(0, deadline - time.monotonic())) + except Empty: + break + self.raw.extend(data) + assert len(self.raw) < 4 * 1024 * 1024, "unbounded terminal output" + self._render(self.decoder.decode(data)) + + def resize(self, columns, rows, *, notify=True): + self.api.hresult( + self.api.ResizePseudoConsole(self._console, _Coord(columns, rows)) + ) + self.columns, self.rows = columns, rows + self.cells = [[" "] * columns for _ in range(rows)] + self.x = self.y = 0 + + def _assert_restored(self): + assert self._receipt.stat().st_size < 4096, "unbounded mode receipt" + receipt = json.loads(self._receipt.read_text(encoding="utf-8")) + assert set(receipt) == {"before", "after", "exit"}, ( + "missing child console proof" + ) + assert receipt["before"] == receipt["after"], ( + "child console modes/cursor not restored" + ) + assert receipt["exit"] == self.process.poll(), "wrapper/CLI exit mismatch" + self._assert_cursor_and_screen_restored() + + def assert_no_terminal_controls(self): + # ConPTY synthesizes VT framing even for plain error text. Verify + # actual child modes above and reject application alternate-screen + # entry, rather than mistaking the host's framing for CLI output. + assert b"\x1b[?1049h" not in self.raw + + def close(self): + errors = [] + if self.process is not None: + if self.process.poll() is None: + # Also terminates an unassigned suspended wrapper if job + # assignment failed; job closure owns all descendants. + self.api.TerminateProcess(self.process.handle, 1) + self.api.TerminateJobObject(self._job, 1) + if self.api.WaitForSingleObject(self.process.handle, 5000) != 0: + errors.append("ConPTY child did not terminate") + if self._attributes is not None: + self.api.DeleteProcThreadAttributeList(self._attributes) + self._attributes = None + if self._console.value: + closer = Thread( + target=self.api.ClosePseudoConsole, + args=(self._console,), + daemon=True, + ) + closer.start() + closer.join(timeout=5) + if closer.is_alive(): + errors.append("ClosePseudoConsole timed out") + self._console = wt.HANDLE() + self._stop.set() + if self._writer is not None: + self._writer.join(timeout=5) + if self._writer.is_alive(): + errors.append("ConPTY writer did not terminate") + if self._reader is not None: + self._reader.join(timeout=5) + if self._reader.is_alive(): + errors.append("ConPTY reader did not terminate") + for handle in reversed(self._handles): + if not self.api.CloseHandle(handle): + errors.append("ConPTY handle did not close") + self._handles.clear() + self._temporary.cleanup() + assert not errors, errors + + Terminal = _WindowsTerminal + + +def _conpty_child(receipt_path, redirect, argv): + """Test-only wrapper: inspect this console, not the parent's pipe handles.""" + import msvcrt + + api = _WindowsAPI() + input_fd = os.open("CONIN$", os.O_RDWR | os.O_BINARY) + output_fd = os.open("CONOUT$", os.O_RDWR | os.O_BINARY) + + def snapshot(): + modes = [] + for fd in (input_fd, output_fd): + mode = wt.DWORD() + api.check(api.GetConsoleMode(msvcrt.get_osfhandle(fd), ct.byref(mode))) + modes.append(mode.value) + cursor = _CursorInfo() + api.check( + api.GetConsoleCursorInfo(msvcrt.get_osfhandle(output_fd), ct.byref(cursor)) + ) + return {"modes": modes, "cursor": [cursor.dwSize, bool(cursor.bVisible)]} + + try: + before = snapshot() + result = subprocess.run( # noqa: S603 - fixed test binary/argv, no shell + argv, + stdin=subprocess.DEVNULL if redirect == "input" else input_fd, + stdout=subprocess.DEVNULL if redirect == "output" else output_fd, + stderr=output_fd, + timeout=60, + check=False, + ) + receipt = {"before": before, "after": snapshot(), "exit": result.returncode} + Path(receipt_path).write_text(json.dumps(receipt), encoding="utf-8") + return result.returncode + finally: + os.close(input_fd) + os.close(output_fd) + + +@contextmanager +def socket_peer(respond): + """Threaded hostile HTTP peer: every unexpected request is recorded/fails.""" + requests = [] + failures = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - HTTP handler interface + request = (self.command, self.path, self.headers.get("Authorization")) + requests.append(request) + try: + status, body = respond(*request) + except Exception as error: # fixture errors must reach the test thread + failures.append(error) + status, body = 500, {"unexpected": True} + encoded = json.dumps(body, ensure_ascii=False).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + try: + self.wfile.write(encoded) + except (BrokenPipeError, ConnectionResetError): + pass # cancellation closes the connection, not the test response + + do_POST = do_GET + do_PUT = do_GET + do_PATCH = do_GET + do_DELETE = do_GET + + def log_message(self, *_): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", requests + assert not failures, failures + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + assert os.name == "nt" and sys.argv[1] == "--conpty-child" + raise SystemExit(_conpty_child(sys.argv[2], sys.argv[3], sys.argv[4:])) diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md new file mode 100644 index 000000000..570f56115 --- /dev/null +++ b/docs/design/cli-terminal-experience.md @@ -0,0 +1,459 @@ +# Workstream terminal experience — implemented first slice and proposed extensions + +Status: WS-CLI-001-48 implements the bounded read-only contributor slice below; +the broader workspace and mutation experience remains proposed. This is not a +new product/API contract. The opt-in `workstream tui` shares the existing Go +binary and public client. Explicit commands in [the CLI reference](../../cli/README.md) +remain the non-interactive interface for humans and agents. Implementation is +not a claim of deployment, completed native verification or public distribution. + +Source reconciliation: public operations below were checked against `main` +`7f6a71a69` on 2026-10-23. The release automation from +[WS-CLI-001-44](https://github.com/Flow-Research/workstream/blob/47760fa9dacf3d2000a073ee41c275c7c8b43ba6/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-44.md) +is merged; no public binary publication is established. The project catalogue +in [PR #665](https://github.com/Flow-Research/workstream/pull/665) is merged and +available to the explicit CLI, but is not used by this contributor workbench. +The first slice is reconciled with main `2e6cee003`; recheck merged +owners before implementing any later slice. + +## Product shape + +The terminal should feel like a precise control room: quiet dark slate, a cyan +focus line, peach for a deliberate action, and enough negative space to read the +work. The useful sequence is **browse → inspect → act → observe**. It is not a +simulation of system health, a second policy engine, or a dashboard of invented +scores. Show a fact only when a public response supplies it; label the time and +scope of each observation. No animation, percentage, or “complete” badge should +suggest that an asynchronous check, Review, acceptance, award, or fulfillment +has happened before the matching backend fact is observed. + +One launch opens the contributor workbench. Project selection comes from +`workstream me projects`, which lists projects where this actor has an exact +grant; it is not a public project directory. Within a selected project, `Ready` +uses `workstream task ready PROJECT_ID`, while `Mine` and `Submissions` use the +actor-owned collections filtered to that project. A person can change workspace +to Manager, Finance, Operator, or Audit explicitly. Each workspace uses its own +public routes and server-selected disclosure; a displayed role, permission or +`next_action` is never local authority to execute a later operation. If a +workspace's read is denied, show the denial, not an empty queue or a substitute +projection. An administrator with covering authority may not have an exact +project grant in `me projects`; Manager, Finance, Operator, and Audit therefore +also offer **Open exact project ID**. They do not pretend that recent local IDs +or `me projects` are a complete administrative project catalogue. There is no +contributor-wide project directory. Merged PR #665 +provides `GET /api/v1/projects` and `workstream project list`: one authorized +page of project ID, name and status, available only to current system-scoped +Project Manager, Operator, Finance Authority or Audit Authority readers. An +Access Administrator alone, exact-project administrative grants and contributor +grants do not enumerate this catalogue. This is the backing API for a later +system-reader picker, not a replacement for `me projects` +or a grant to read a guide or mutate a project. Exact-project managers/finance/ +audit readers still need selection through separately held contributor membership, +if any, or **Open exact project ID**; their administrative grant alone does not +populate `me projects`. A catalogue denial must never trigger a hidden fallback +request. System-reader +catalogue pages follow the same explicit pagination and error rules below. + +The TUI opens only on an interactive terminal; non-TTY use receives a bounded +error pointing to ordinary `workstream --help` and `--output json`. It uses the +same supplied `WORKSTREAM_API_URL` and `WORKSTREAM_TOKEN` contract as the CLI; +there is no Workstream login, token acquisition, keychain, browser callback or +credential store. It never prints a bearer. A project or resource ID may be +carried internally from a selected row to a detail/write request, eliminating +UUID transcription; that ID remains visible in the detail and confirmation. + +### Information architecture + +| Workspace | First surface | Inspect | Candidate deliberate action | +|---|---|---|---| +| Contributor | Exact-grant project picker; within it Ready, Mine, Submissions | Contributor-safe task detail, work context, locked guide and intake requirements; own preparation, Submission and checker evidence | Claim, start, prepare ZIP, then separately create Submission from a ready admission | +| Manager | Selected project's task queue and summary | Guide status/readiness, task/context, preparation counts, Submission/checker history | Create/screen/release a task; guide, import, grants or revision repair stay explicit CLI handoffs until their exact forms are designed | +| Finance | Selected project's current and retained ContributionPolicy versions | Exact policy rules and contribution evidence/earned award facts | Draft/update/publish/retire only through explicit reviewed forms; no payout control | +| Operator | Project-scoped checker retry sources, uncertain deliveries, and task/custody diagnostics | Exact source run/event/job and locked references | Explicit reasoned retry or reconciliation; no inferred recovery from a list | +| Audit | Exact project/task selector | Locked context and paged lifecycle evidence | Read only | + +This is navigation, not a promise that every candidate action ships in the +first TUI slice. The overall goal is the connected contributor and management +journeys below. The smallest first slice is the read-only contributor workbench +specified under Implementation fit; later slices add one reviewed operation +at a time until that journey can be performed without an implicit write chain. +Administrative actor/grant writes stay in the explicit CLI until separately +designed; there is no global actor picker or generic raw-HTTP console. Genuine +human Review decisions are not part of the current CLI inventory and must not +be pictured as working TUI actions. + +## Main screen, 120 × 40 + +Illustrative values and local observation time below are not current system +claims. `Ready` and `Mine` are distinct server projections; opening a row makes +a fresh exact-resource read. The 21-row drawing reaches at most 98 cells inside +a 120×40 terminal, leaving room for margins and long task text; the lower rows +become scrollable detail rather than decorative filler. + +```text + WORKSTREAM / CONTRIBUTE Project: Aster Research v 14:02 observed + ──────────────────────────────────────────────────────────────────────────────────────────────── + Ready 03 Mine 02 Submissions 01 Evidence / filter ? keys + + READY IN THIS PROJECT (page 1) │ SELECTED TASK + > Evaluate source evidence ready │ Evaluate source evidence + Trace failure modes ready │ Status: ready Est: 45 min + Test intake package ready │ Skills: analysis, testing + │ + Your active work │ Produce attributable findings for the + Draft result matrix in_progress │ supplied evidence. [more ↓] + Revise benchmark notes needs_revision│ + │ Locked terms: unpaid / exact awards + │ shown from the task's policy version + │ + │ NEXT Inspect task ↵ + │ Claim… a + │ + ──────────────────────────────────────────────────────────────────────────────────────────────── + ↑↓ select ↵ inspect a actions g project w workspace r refresh c CLI ? help q quit + Ready: observed 14:02 · page 1 · more available │ A read is not a reservation or authority +``` + +The left column is a worklist, not a merged cross-project feed. Counts on the +top line refer only to the currently fetched page or to separately labelled +summary facts; do not present page length as a project-wide count. If a list is +paged, show `page 1 · more available` and let `n` fetch the next page with the +same project, filters, limit and opaque cursor. Never decode a cursor, silently +walk pages, or mix cursor families. Before exact detail loads, the right pane +says “Open to inspect”; ready summaries do not contain full instructions. + +Inside detail, use a short vertical sequence rather than modal nesting: +`Overview → Instructions → Locked context → Intake → History`. Long contents +scroll in one viewport. The locked guide's document labels, media types, sizes +and hashes are displayed from task context after an active assignment; download +is an explicit action to an existing directory, with no automatic PDF open or +pre-download integrity claim. The existing CLI verifies bytes during download. + +## Compact screen, 80 × 24 + +At narrow width, list and detail are sequential views, not cramped columns. +The same selection and exact project survive the transition. No essential state +is encoded by color or truncated-only text. This 18-row drawing's longest line +is 68 cells inside an 80×24 terminal. + +```text + WORKSTREAM / CONTRIBUTE Aster Research observed 14:02 + ──────────────────────────────────────────────────────────────── + Ready Mine Submissions Evidence / filter ? + + READY · page 1 · more available + > Evaluate source evidence ready + Trace failure modes ready + Test intake package ready + + Estimated: 45 min · analysis, testing + Locked terms: see task detail + + ↵ inspect selected task + a actions · g project · w workspace · n next page + + ──────────────────────────────────────────────────────────────── + ↑↓ select r refresh c CLI ? help q quit + A ready row is not reserved; claim is decided by Workstream. +``` + +Below roughly 70 columns or 20 rows, use a single plain list/detail view with +wrapped labels and vertical scrolling. If even the help/confirmation cannot be +displayed legibly, stop before any write and give the exact command path. Resize +must retain the selected resource but may reflow content; it must not commit an +action. Alternate-screen rendering, cursor keys, Enter, Tab, Esc, `?`, letters, +and Ctrl+C must work across supported Linux, macOS and Windows terminals without +Unix signals, shell-specific bindings, mouse, clipboard, or Unicode glyphs as +requirements. Mouse support may be additive only. + +## Keyboard drill and action contract + +1. `workstream tui` checks terminal/configuration and loads the actor's exact-grant + projects. `g` opens the project picker; `↑/↓`, type-to-filter fetched names, + and `Enter` choose one. Filtering is local to fetched rows and is labelled + so it cannot imply server-wide search. `w` changes workspace; a change never + executes a privileged call by virtue of a cached role. +2. `↑/↓` selects a row, `Enter` opens a fresh detail, `Tab` advances the + detail sections, `Esc` returns. `r` refreshes the current view and clears + stale cursor continuation. `/` filters the current fetched page, `n` loads + the next authorized page, and `?` shows all current keys. No rapid navigation + key invokes a write. +3. `a` opens only actions with a designed flow for the selected resource. A + server `next_action` may help label a candidate, but never proves eligibility. + `Enter` opens a review sheet containing action verb, exact project/task and + assignment, relevant locked policy/admission/revision identifiers, packet + fields or file selection, and the expected meaning of a successful receipt. + Before Claim, the sheet also shows the exact ContributionPolicyVersion and + both accepted-submission and completed-review terms from the fresh + contributor-safe task detail: each is either `unpaid` or the returned + instrument, unit and decimal-string quantity rows. If that detail refresh + is stale, denied, incomplete or failed, Claim cannot be confirmed. These + are potential earned terms, not a promise of acceptance or fulfillment. + `y` sends exactly one request **only while the confirmation itself has focus**; + pasted text and keys typed into a field cannot commit it. `Esc` is the + default cancellation. Once sent, disable duplicate dispatch from repeated + keys, resize events or network callbacks until the result is classified. + No action chains + claim → start, preparation → Submission, guide approvals → activation, or + checker result → acceptance. +4. After a known receipt, show its exact result and a separate `r` to observe + the current resource. After a canonical denial, show the server code and + correlation ID if present. After an uncertain write, freeze the action + context on a recovery sheet; do not auto-retry or offer a fresh key as the + primary action. The observation link is explicitly not rollback proof. +5. `c` displays an inert argv recipe for the equivalent existing CLI command, + with selected IDs as separate arguments. An optional copyable rendering may + use only an explicitly chosen and tested POSIX-shell or PowerShell quoting + mode; never assume that one quoted string works across platforms, especially + for packet text or paths. It does not launch a shell, place credentials in a + command, assume OS clipboard access, or imply that a TUI-only action exists + in the explicit CLI. If exact recovery inputs are not available, it says so + rather than fabricating a command. + +The footer uses a fixed key vocabulary. `q` warns before exit when a write is +unresolved; Ctrl+C always permits exit and preserves the private receipt. Neither +resolves or replays it. For a TUI write to +ship, a bounded private operation receipt must be committed **before** sending +the one request. It binds the API origin, current actor identity, action, exact +project/resource selectors, key or admission, packet/reason/revision inputs, +and a digest/fingerprint of any source file; it excludes the Flow bearer and +raw ZIP bytes. A source file must still be present and verified unchanged for +manual preparation replay. The receipt is app-private per operating-system +user (restrictive POSIX mode or Windows ACL), atomic against interruption, +bounded in count/size, and safe under concurrent TUI processes. Startup offers +an explicit recovery list only for the same origin and actor; it never sends a +write while reconciling. Expired receipts need a deliberate resolution or +privacy-preserving purge flow, not silent deletion or a new key. Exact storage +format, lifetime, path, filesystem protections, and source-fingerprint +semantics are an implementation decision gate to be reviewed before a mutation +slice. This is **TUI-only** state; the non-interactive CLI must remain stateless +and must not gain implicit files. Do not create a generic all-POST journal or +declare a history read sufficient for recovery. An interruption just before or +after sending is uncertain until an exact replay receipt or adequate +authoritative evidence establishes the outcome. + +### Contributor walkthrough and truth labels + +| Screen/step | Public owner and exact CLI path | Truthful label | +|---|---|---| +| Project and ready work | `me projects`; `task ready PROJECT_ID`; `task show TASK_ID` | “Available to inspect”, never “reserved” | +| Active assignment | `me assignments --project PROJECT_ID`; `task context TASK_ID`; `task requirements TASK_ID` | “Your current assignment”; fresh read may change | +| Claim/start | `task claim TASK_ID --idempotency-key UUID`; separate `task start TASK_ID --idempotency-key UUID` | “Claim recorded” / “Start recorded”, only after complete receipt | +| Locked instructions | `task context TASK_ID`; `task guide TASK_ID [--download DIR]` | “Locked guide”; bytes verified only after successful explicit download | +| Intake | `task prepare TASK_ID --assignment UUID --file ZIP --idempotency-key UUID` plus exact packet/revision flags | “Preparation”; `ready` receipt supplies admission; no Submission yet | +| Immutable attempt | `task submit TASK_ID --assignment UUID --admission UUID` plus the same packet | “Submission created”; not acceptance or a Review | +| Evaluation and outcome | `task preparations TASK_ID`, `task submissions TASK_ID`, `submission show SUBMISSION_ID`, `submission findings SUBMISSION_ID` | Checker findings are evidence; `accepted`, `needs_revision`, `rejected` and `review_pending` appear only from public stored outcome | +| Contribution evidence | `contributions --project PROJECT_ID` | Earned immutable facts and exact award terms, not proof of fulfillment | + +Preparation's packet is the exact assignment, optional predecessor, summary and +attestation, with an exact revision preparation ID/digest acknowledgement when +required. A `ready` admission can be used for the separate Submission request +with the same packet. Other preparation states are not success or permission to +submit; show their bounded status/findings and an observational read. A failed +intake does not create a Submission. For checker-requested revision, show the +current `revision_context` and exact parent pair, and warn that human Review +revision is not yet a delivered CLI journey. After submission, `review_pending` +means pending human review under the locked policy; passing checks do not make +the checker an acceptor. Under a supported false ReviewPolicy, required checks +can invoke the shared authorized FinalAcceptance, but the UI waits for the +stored outcome and ContributionRecord rather than predicting it. + +Unknown-response language must be operation-specific. Claim/start and most +keyed writes retain action, resource, reason/inputs and caller-owned key for +manual unchanged replay; a later read does not prove rollback. Preparation +requires the same task, assignment, ZIP bytes, packet, revision acknowledgement +and preparation key. Submission replay instead uses the exact admission and +packet—not a generic idempotency key. Preparation and Submission history do not +expose all original replay inputs, so their mere presence/absence cannot clear +an uncertain receipt. An expired/revoked Flow bearer or grant +can make even an exact replay fail; the TUI never treats a prior read or receipt +as authority. An observed `needs_revision` is not a prompt to silently submit a +new ZIP; the contributor inspects the current context and requirements first. + +Checker evidence is a separate view from outcome. Show each returned finding's +`check_id`, severity/status, message and suggested fix, and its optional +`path:line` only when present; do not synthesize a failed check from a missing +field. Distinguish blocking, warning and `coverage_incomplete` counts. A +coverage gap may route to `needs_revision` under a false ReviewPolicy rather +than acceptance; display the stored Submission outcome/reason, not a TUI verdict. + +### Management walkthrough and bounded handoffs + +Management is a connected setup → release → observation → diagnosis experience, +not a mixed-role dashboard. Workspace choice changes navigation only; a manager +cannot enter Finance or Operator by reusing a manager response. Until a step's +forms and exact recovery are implemented, `c` presents the existing command's +inert argv/input-file recipe and makes the handoff explicit. It never executes +that command or claims the whole flow is already a TUI capability. + +| Step | Existing public CLI operation | Screen or handoff boundary | +|---|---|---| +| Select and inspect | Exact-grant `me projects` or exact-ID entry; `project show`, `project access PROJECT_ID`; pending system catalogue as above | Current project identity and safe projection; access is observation, not a write grant | +| Declare and prepare guide | `project create`; `project guide create`, `edit`, `upload`, `setup`, `proposal`, `post-policy` | Exact project/guide/compilation/policy forms remain CLI handoffs; setup proposals are not runtime checker implementations | +| Lock approved rules | `project guide set-review-policy`, `set-revision-policy`, `approve-pre`, `approve-post`, `readiness`, `activate` | Separate operations and confirmations; retain default human review and explicit false-policy branch; readiness is not activation | +| Resolve guide feedback | `project guide sufficiency create`, `acknowledge-warnings`, `correct`, `correct-post`, `dispatch-correction` | Diagnostics/warnings are not approvals; correction dispatch needs its saved exact receipt, not a new generic key | +| Set contribution terms | Finance `project contribution-policy current`, `list`, `show`, `create`, `update`, `publish`, `retire` | Separate Finance workspace; publication is not guide binding, earning or fulfillment | +| Admit contributors and work | `project role-grant list`, `show`, `candidates`, `issue`, `revoke`; `project task create`, `screen`, `release`; `project task-batch` commands | Grants/import remain CLI handoffs; task release is separate from create/screen and locks the backend-owned context | +| Observe attempts | `project tasks`, `task`, `summary`, `assignments`, `preparations`, `submissions`, `submission`, `checker-runs` | Manager projections, not contributor-shaped substitutes; show exact locked contexts/requirements through their separate manager commands | +| Recover or audit | Operator `operations checker retry-sources`, `retry`, `operations delivery list`, `reconcile`, `operations artifact` commands; Audit `audit task locked-context`, `evidence` | Distinct authorized queues and exact-resource reads; no fabricated human Review or provider/payout controls | + +The remaining current guide/artifact-policy and revision-repair commands stay +available in the explicit CLI; listing a family here does not authorize a +generic JSON editor or imply every command is eligible for a TUI form. Human +Review/assignment and its revision journey, compensation fulfillment, and +runtime reputation remain separate product gaps, not empty working screens. + +For future Manager/Finance writes, retain exact action, project and resource +selectors, unchanged input contents/reason, and original caller key before +dispatch. Preserve `If-Match` on guide policy edits and any operation-specific +digest/lineage acknowledgement; a policy/detail read does not replace them. +Guide correction dispatch is different: use the saved correction receipt and +its exact project/guide/compilation, never synthesize an idempotency key. Operator +checker retry binds the exact terminal source run, project, reason and key; +delivery reconciliation binds the exact UNKNOWN event, project, reason and +key; artifact retry also binds the expected source-job CAS and task/optional +Submission selectors. None is interchangeable with another recovery action. +Before exposing each write, inspect its typed client and public schema and prove +the specific unknown-response path; no family-wide receipt shortcut is allowed. + +## Failure and accessibility states + +The status line distinguishes `Loading…`, `Observed HH:MM`, `Stale: refresh +failed`, and `Outcome unknown: recover deliberately`. Use a static ellipsis, +not a synthetic progress meter. Keep visible-screen read polling bounded and +explicit: initial load, user refresh, or a short documented observational +interval only on a visible pending preparation/evaluation screen; never poll +background project queues or mutate on a timer. Apply capped backoff after +transient failures and cancel obsolete reads on navigation. Tag each request +with a screen generation, actor, API origin and selected resource; discard late +responses that no longer match. A refresh does not overlap duplicate writes. +For the proposed foreground observation, start no sooner than five seconds +after the last completed read, then space subsequent pending-state reads at +10, 20 and at most 30 seconds; stop after two minutes on that screen and offer +manual `r`. Keep at most one read in flight. A transient failure uses the next +delay rather than a tight retry; a definitive denial stops polling immediately. +The implementation must validate this budget against the existing transport +timeouts and server limits before shipping. + +When a transient read fails, previously rendered data may remain only with a +prominent stale label and direct actions disabled until fresh inspection. On +401, 403 or a concealed 404, immediately clear protected detail and cached +pages for the affected scope and disable actions; do not leave prior private +content visible as if still authorized. A denied or concealed resource is not +a zero-item success. Show the error code/correlation ID when safe, without +echoing paths, token, raw network details or untrusted control bytes. For a +missing/rejected bearer, say “Save the recovery receipt if needed, exit, renew +the token in your existing environment, then relaunch.” A child process cannot +see environment changes made later in its parent shell; `r` alone cannot load +a renewed token. Do not offer login or claim to distinguish expiration from +other 401 causes unless the public error actually does. An actual local ZIP +read/upload can show bytes transferred against known file size, but reaching +100% is not server verification, ready admission or Submission creation; label +those as pending until their exact receipts/readbacks arrive. Do not invent +percentages for backend intake or post-submit evaluation. + +Use textual state words alongside color. Never blink. Respect `NO_COLOR`, +monochrome and low-color terminals; truecolor is a polish layer, not a +requirement. Suggested dark palette: slate canvas `#101923`, lighter panel +`#192632`, primary text `#EAF2F4`, muted text `#A9BAC3`, cyan focus `#69D7E3`, +peach action `#F2AE91`, amber caution `#E9C37B`, red error `#ED8991`. Select +light-background and ANSI-16 equivalents by terminal capability; all selection +and severity survive as ASCII marker plus label. Avoid half-width decorative +glyphs, terminal hyperlinks, inaccessible dim-only metadata, and color-as-status. +Untrusted project/task text is escaped, wrapped, and never interpreted as ANSI, +OSC links, shell commands, filesystem paths, or control sequences. A plain +single-column mode should be available for screen readers and troublesome +terminal emulators; it keeps the same keys, action confirmation and exact +failure wording, and does not fall back to a different authorization path. + +## Implementation fit and explicit gaps + +The existing Go binary has Cobra commands and a typed public client under +`cli/internal/api`; the TUI should call that client through the same validated +operations, not shell out to `workstream`, parse human text, introduce private +backend routes, or copy policy decisions. This is a candidate integration +boundary, not authorization to refactor the client. Official upstream +[Bubble Tea](https://github.com/charmbracelet/bubbletea/blob/main/README.md) +documents an event/model/update/view terminal architecture; +[Bubbles](https://github.com/charmbracelet/bubbles/blob/main/README.md) provides +list, viewport, help, and file-picker primitives; +[Lip Gloss](https://github.com/charmbracelet/lipgloss/blob/main/README.md) +supports terminal color-profile downsampling. The first slice selects Bubble +Tea 2.0.10, Bubbles 2.2.1 and Lip Gloss 2.0.6, with resolved modules in the CLI +lockfiles. Native terminal verification remains required on all four supported +targets; accessibility certification is not implied. + +### Implemented first slice + +The opt-in **read-only contributor workbench** in the existing Go binary provides: +launch/terminal guard → actor binding → `me projects` page → selected project's +Ready/active Mine/Submissions page → fresh task or owned Submission detail. +This first slice uses exactly nine existing typed client reads: `Profile`, +`MyProjects`, `ReadyTasks`, `MyAssignments`, `MySubmissions`, `ContributorTask`, +`WorkContext`, `SubmissionRequirements` and `Submission`, as bounded in +[WS-CLI-001-48](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md). +Ready opens contributor-safe task detail and both locked contribution terms; +active Mine additionally offers explicit context/intake sections. This is a UI +scope cut, not a claim that the backend context/requirements endpoints require +assignment. Guide document disclosure remains backend-owned; no guide bytes +are fetched. Owned Submission detail shows stored outcome and finding counts. +Detailed checker findings and contribution Evidence are clearly labelled +manual CLI handoffs only, not in-TUI evidence browsers or automatic reads. + +This slice includes explicit pagination, scoped refresh, stale/denied states, +local filtering labels, safe terminal text, 120×40/80×24 reflow, plain/no-color +mode, keyboard help and inert CLI handoff. No background polling is necessary +in this slice. It excludes all API writes, downloads/local export, automatic +token refresh, recovery files, new management forms, and the pending catalogue. +Use the existing actor profile read to bind the current actor; that GET may +perform the backend's existing admission/last-seen/audit effects, so “read-only” +does not mean an unaudited or side-effect-free backend. No explicit CLI output, +exit status, flags, help for existing commands, or implicit local state changes. + +The first slice makes no `cli/internal/api/` changes. A later in-TUI +contribution Evidence slice needs one bounded client prerequisite: a typed +single-page contribution read that returns validated records and the opaque +continuation. The current exported `Contributions(..., io.Writer)` walks up to +100 pages and discards the continuation in its merged output; it cannot back +explicit `n` navigation. Extend the existing client owner using its transport +and record validators, and let the existing complete-export CLI retain its +current behavior. Do not parse stdout, silently traverse pages in the TUI or +implement a duplicate endpoint client. Evidence stays an explicit manual CLI +handoff in the first slice; only a later slice implements and tests this port. +In that later slice, a page with a continuation must +produce exactly one request until the user asks for the next page. Retain the +existing multipage CLI export regression alongside that new proof. + +The first slice's bounded implementation record must select exact dependencies +and allowed files and name focused interaction, hostile-response, denial, +resize and terminal-teardown tests. It must not add the deferred typed-page +port or detailed-findings decoder. A later Evidence record must include only +that narrowly scoped shared typed-page prerequisite, not a general client refactor. +Proof must include a built TUI/public-API journey and unchanged explicit CLI +text/JSON/non-TTY behavior; render snapshots alone are not runtime proof. +This proposal is not authorization to begin that next change automatically. + +### Dependency map to the end-to-end experience + +| Boundary | Dependency and next usable result | +|---|---| +| Contributor read-only workbench | Nine existing typed reads and merged backend guards → project/Ready/active Mine/Submissions browse/inspect with explicit pagination; detailed findings and Evidence are manual CLI handoffs; no API-client edit or catalogue dependency | +| Later in-TUI contribution Evidence | Narrow shared typed single-page contribution port and preserved complete-export regressions → explicit per-page evidence browsing; not a first-slice prerequisite | +| Claim/start | Read workbench + reviewed per-operation private receipt and exact-key recovery → separate claim and start confirmations | +| Prepare/submit | Claim/start + ZIP source integrity + distinct preparation/admission replay receipts → separate intake and immutable Submission, then stored evaluation/outcome/contribution evidence | +| System workspace project picker | Merged #665 supplies the public API and explicit CLI; later TUI integration uses the four-role system catalogue only, retaining exact-grant/exact-ID entry for actors outside that scope | +| Management read workspaces | Existing Manager/Finance/Operator/Audit typed reads + explicit project selection → scoped queues, details and CLI handoffs; not blocked on catalogue if exact ID is known | +| Management writes | Each read workspace + that operation's reviewed form/authority/recovery proof → setup/release/policy/recovery actions individually, not an all-POST engine | +| Human Review or fulfillment | Stable public contracts and tested lifecycle guards for those specific journeys → separate design/implementation; not enabled by TUI or catalogue work | +| Public binary delivery | Merged #664 automation + protected first publication and independent install proof → distributable binary; neither prerequisite nor proof of local TUI correctness | + +The main mutation gate is implementing and proving the private, +operation-specific receipt boundary above for one-shot writes across TUI +crash/terminal loss. Do not expose a mutating action until that exact +operation's unchanged inputs and privacy boundaries are proven. +Other implementation gates: later TUI integration of the existing backend-owned +four-role project catalogue; real API-backed detail/denial tests, narrow terminal +layout snapshots at 120×40 and 80×24, keyboard-only and no-color checks on all +supported platforms, and proof that the explicit CLI/help/JSON behavior remains +unchanged. A TUI is an interface over currently local/backend capability, not +proof of hosted deployment, binary publication, human Review, or payout. diff --git a/docs/engineering/cli-release.md b/docs/engineering/cli-release.md index 15ecebedc..5d76a91cb 100644 --- a/docs/engineering/cli-release.md +++ b/docs/engineering/cli-release.md @@ -15,6 +15,41 @@ The native loopback API fixture proves authenticated transport from each installed executable; it does not replace the existing Linux public-API integration suite or prove every deployed backend version. +Each native job also builds the same source as a native executable and runs the +complete socket/terminal suite, `cli/tests/integration/test_tui_http.py`, on a +POSIX PTY (Linux/macOS) or real ConPTY (Windows). These interactive checks cover +keyboard/help, resize, plain/NO_COLOR, quit/Ctrl+C/pending quit and denial/cache +clearing on every target. POSIX targets additionally test real SIGTERM teardown; +Windows does not claim POSIX-signal semantics. Installation smoke alone is not +terminal proof. +Program-run/output failure cleanup is a separate Go test boundary in +`cli/internal/command/tui_test.go`, included in the existing native `go test ./...`; +signal teardown does not prove a program error. + +The terminal suite uses a fresh virtual environment and the complete exact +wheel dependency closure in +[`requirements-native.txt`](../../cli/tests/integration/requirements-native.txt). +Installation uses isolated pip, required hashes and binary wheels only; a +missing dependency, wheel or hash fails the job without an unpinned/source-build +fallback. Only pytest and its closure are installed: the unrelated TLS/HTTP2 +fixture loads backend cryptography when invoked, not when shared HTTP fixtures +are imported. Its existing real TLS/HTTP2 GOAWAY regression remains unchanged. +Third-party pytest auto-loading and inherited selection options are +disabled. The proof runner records raw collected node IDs, deselection and all +setup/call/teardown reports, including xfail metadata. It requires nonempty, +unique collection and exactly one successful phase sequence for every collected +test. Deselection, skips, xfail/XPASS, collection errors, missing/duplicate reports, +nonzero exit and interrupted execution cannot produce a completion receipt. +Adversarial runner self-tests execute before the terminal suite on each target. + +`reports.json` and its hash-bound `completion.json` are uploaded separately as +`cli-tui-evidence-SOURCE-PLATFORM-run-RUN_ID-attempt-ATTEMPT`. The binding names +the checked-out source SHA, actual native target and workflow run/attempt. +Assembly independently verifies all four receipts against that exact binding; +missing, stale or altered evidence fails assembly. Test evidence is neither a +release archive member nor a sixth release asset. Cancellation remains a failed +or cancelled native job, never successful proof or authorization to publish. + Before the first release, a repository administrator must configure the `cli-release` GitHub environment with required human reviewers and a deployment branch restriction to `main`, with self-review prevention enabled, then inspect diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index c025f1f32..81ee9d92a 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -40,6 +40,11 @@ hosted S3 operation remains a separate proof boundary. The [CLI distribution path](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-44.md) prepares native four-platform GitHub Releases proof and a protected manual publisher. No approved release tag or public binary has been published yet. +The opt-in [contributor terminal workbench](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) +adds local read-only project/task/Submission navigation through existing typed +public reads. Detailed findings and contribution evidence remain explicit CLI +handoffs; TUI mutations and management are separate work. Neither this interface +nor release automation establishes hosted operation or a published binary. ## Product Goal @@ -406,6 +411,15 @@ retried. The separate CLI release workflow builds read-only pull-request previews for Linux amd64, Intel/Apple Silicon macOS and Windows amd64, then permits only a manually approved exact-tag publication. Its presence is not a published binary, deployed backend or complete proposed workflow catalogue. +`workstream tui` is a separate opt-in read-only presentation of exact-grant +projects, Ready tasks, active own assignments and own Submissions, with fresh +task/attempt details and explicit Mine context/intake reads. It uses the existing +API client, one page per deliberate continuation and no local authority rules. +Denial removes affected content; late responses cannot replace the current +view. Detailed findings/Evidence are inert CLI handoffs, not automatic export. +All writes, downloads, polling, management forms and operation-specific recovery +remain outside this bounded workbench. Explicit text/JSON commands stay usable +without a terminal. The [Operator checker CLI](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-29.md) adds explicit registry register/read and project-bound terminal retry-source list/retry commands over the existing public API. Registry publication is not @@ -519,7 +533,7 @@ cannot be reused as post-submission review-gate evidence. See the | --- | --- | --- | --- | | Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls; canonical `/actors/me` self-read with duplicate `/auth/me` removed; suspension denies both self-read and self-update; CLI privileged exact-actor/profile and identity-link reads and explicit separate profile/link lifecycle writes remain distinct from self-service and backend guards | Final end-to-end operational and conformance proof | | Authorization kernel | **Live foundation** | Closed action/permission catalogues; deny-by-default evaluation; grants; exact-project, privacy-safe candidate read now consumed by the CLI; fixed services; rate controls; opaque transaction-bound PREP; atomic decision evidence; CLI scope-filtered administrative grant/catalogue reads and explicit keyed grant issue/revoke consume existing public APIs without local authority decisions | Activate only the remaining owner-proven TASK, checker, REV, and CON boundaries; remove obsolete authority after replacement paths are live | -| System project catalogue | **Bounded public API and CLI read** | WS-CLI-001-46 adds `GET /api/v1/projects` and `workstream project list` for current system Project Manager, Operator, Finance or Audit `project.read` grants; each signed page exposes only ID, name and status. `me projects` retains exact-grant membership semantics. | TUI runtime, deployed CLI and hosted conformance remain separate | +| System project catalogue | **Bounded public API and CLI read** | WS-CLI-001-46 adds `GET /api/v1/projects` and `workstream project list` for current system Project Manager, Operator, Finance or Audit `project.read` grants; each signed page exposes only ID, name and status. `me projects` retains exact-grant membership semantics. | System-catalogue TUI picker integration, deployed CLI and hosted conformance remain separate | | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; draft guide and review/revision policy writes with fresh-authorized exact replay of original receipts; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation; Project Manager guide list, guide detail, activation readiness dry run and verified original read-back (PILOT-READ-3); the CLI exposes existing administrative active-chain and retained sufficiency/artifact-policy reads, keyed human sufficiency writes, and separate manual artifact-policy draft create/update with backend-owned policy decisions | Public original upload, task examples, Celery setup and separate approvals are connected locally with recorded inference; prove configured live inference and readers in the intended environment; manual drafts are not compiled activation proposals; deployed CLI remains separate | | Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | | Contribution policy administration | **Public Finance policy workflow; binding administration internal** | Internal Finance Authority adapter-binding lifecycle; public ContributionPolicy discovery/read/create/update/publish/retire with exact Finance Authority and recoverable draft selectors, and a Finance list of every version (PILOT-READ-3); immutable operation and event history; internal exact selected-version validation; CP07 binding with live exact-project manager authority of the selected published version to the active guide generation | The bounded local public drill passed; policy publication alone does not create a contribution, and hosted operation remains unproven | @@ -537,6 +551,7 @@ cannot be reused as post-submission review-gate evidence. See the | Review decision and revision | **Checker revision implemented; human decisions planned** | Review/revision policy identities and mutation authority, with `requires_second_review` fixed false through typed and database boundaries; REV-04A immutable Review storage; REV-04B source storage; REV-04C hidden source-neutral FinalAcceptance/TASK/CON participant; ARCH-04E2-B source AUTH and complete database/audit/outbox closure for hidden automated outcomes. PILOT-08 supplies required project-policy N>=0, assignment-scoped attempts, immediate exhaustion/release, atomic complete-context rebase and acknowledgement, setup-blocked clock pause/resume, PM attention/repair, and durable timeout rejection/reclaim. The CLI now makes the existing exact manager repair POST available with caller-owned key and source, preserving backend eligibility and replay authority | Genuine human decision authority/composition and revision integration remain #554 work under the same intended policy; hosted operation remains unproven | | Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings and project-points units (internal Finance commands); public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete frozen award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction; 04F2 checker remediation and terminal infrastructure retry create no contribution or award. The bounded local public drill produced exactly one false-policy FinalAcceptance, submitter ContributionRecord and project-points award, with no Review | ARCH-04E2-B supplies exact routing authority/evidence, complete-set database closure and audit/outbox under REV-12A4A. Selected production request/completion delivery, 04F3 exact-cause replacement and the 04F4 owner with supported public terminal recovery are implemented; supported false-guide activation is implemented. Only actual Reviews create reviewer records. Obligation/root/ordinal custody remains deferred until fulfillment activation; no public recognition or fulfillment route is live | | Contribution evidence reads | **Public fixed-membership REST and complete CLI export** | PILOT-10A exposes current human self-read and one-project JSON/CSV export for covering Project Managers or exact-project Finance Authorities. Each page has fresh authority and one repeatable-read response transaction; selections through 100,000 contributions retain page-one membership behind signed scope/filter/format/limit-bound cursors, with complete zero/one/two immutable awards, exact decimals, recorded-time filters and formula-neutralized CSV. The CLI follows JSON or CSV continuation internally with unchanged filters, validates cross-page order/identity, and publishes only the complete JSON result or one private no-overwrite CSV | No recognition mutation, fulfillment, payout, deployed-service or published-binary claim | +| CLI and terminal interface | **Public CLI clients and opt-in read-only contributor workbench** | Existing explicit commands retain text/JSON/noninteractive behavior. [WS-CLI-001-48](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) adds exact-grant project, Ready/active Mine/own Submission browsing and fresh details/context/intake through nine existing typed reads, with explicit pagination and fresh-denial handling | Detailed findings/Evidence remain manual CLI handoffs; a shared typed single-page contribution port precedes in-TUI Evidence. TUI writes/recovery and management remain separate. Protected first binary publication and hosted journey proof remain outstanding | | Fulfillment, reconciliation, and audit | **Planned** | Shared audit foundations, provider-neutral adapter convention, AUTH-OUTBOX-02 live dispatcher authority, retained phase audit decisions, Celery delivery/recovery scans and CON-02B custody | Feature-specific handlers and authority, conditional award fulfillment, callbacks, idempotent recovery, reconciliation, bounded operational reads, and release controls | | Runtime diagnostics | **Implemented foundation; release configuration prepared** | Closed structured logging, explicit API/Celery tracing, bounded metrics, safe correlation and optional typed OTLP export; hosted composition accepts only an optional HTTPS collector origin and keeps exporter outage outside product correctness | Restrict broker publishers; configure and secure collector/log access, egress, encryption and finite deletion; prove privacy, correlation and collector outage during the deployed release drill; no deployed monitoring claim | | Frontend and pilot | **Local runtime foundation; release configuration prepared; project inputs prepared** | An original [Terminal Systems guide, example and five task briefs](../pilots/terminal_systems/README.md) provide inputs for fresh project onboarding. The fixed five-task sample, its checker, private runtime continuation and sample-only proof workflow are retired. Checkout-isolated Docker Compose API, prefork Celery, Beat, PostgreSQL, Redis and MinIO remain available, alongside digest-only hosted release composition and the generic external image service. The generic image command tests project samples and publishes a verified digest to ECR without registering or activating it. The opt-in [local agent-loop overlay](engineering/local-agent-loop.md) adds that service, a separately built networkless proof checker and operator scripts; Docker Desktop (Apple Silicon) proof covers registration, checker execution and lifecycle activation, not this project’s requirements. Setup supports inference and explicitly manager-authored rules through the same governed proposal path. Inference is limited to PRE/POST specifications, with strict JSON and bounded correction; PM policy choices remain separate. The retained real Terminal Systems proposal preserves null project limits on the unchanged guide; its independent review predates the PRE/POST-only scope, with ten explicit implementation/runtime gaps and no bindings in that retained proposal. The [project-owned PRE image](../pilots/terminal_systems/checkers/README.md) now implements layout and metadata intake with local rule and networkless container samples; it has no task-name allowlist or shared-runner project code. The independent offline gVisor feasibility spike remains historical mechanism evidence | Obtain a fresh PRE/POST-only correction of the retained proposal, then reconcile the implemented PRE image, implement the remaining POST checks and resolve their runtime/material gaps; register the required images, obtain corrected policy bindings and separate approvals, then activate, import, screen and release tasks. Then prove the real contribution journey, runtime limits and interruption recovery on the intended local/hosted stack. Guide assets, image builds and former sample results are not live-project evidence. Hosted identity/AWS configuration, isolation, diagnostics, backup/restore and broader macOS runtime proof remain; the agent-loop overlay still needs approved guide bindings and a persistent-stack FinalAcceptance drill | @@ -871,10 +886,14 @@ cannot be reused as post-submission review-gate evidence. See the integration and isolated real-API proof accompany the package. [WS-CLI-001-46](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md) adds minimal system project-reader discovery through one public API page and - `project list`, without changing exact-grant `me projects`. Further public - commands and the optional TUI remain planned; binary-release automation is - prepared, while protected first publication remains outstanding. CLI reads - do not complete unfinished product lifecycle work. + `project list`, without changing exact-grant `me projects`. + The [read-only contributor TUI](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) + uses those existing typed reads for one-page navigation and fresh details, + without changing product authority or adding writes. Detailed findings and + contribution evidence remain manual CLI handoffs; broader TUI work remains + separate. Further public commands require their own usable API contracts; + binary-release automation is prepared, while protected first publication + remains outstanding. CLI reads do not complete unfinished product lifecycle work. The public-client drill targets currently usable APIs only; hidden and unfinished lifecycle routes are not completion targets. Draft-guide policy probes additionally cover optional fields, conditional headers and exact @@ -1298,6 +1317,10 @@ Delivered foundations (bounded local journey proven; no deployment claim) WS-CLI-001-41 keyed manual artifact-policy draft create + hash-guarded successor WS-CLI-001-46 current system project-reader catalogue -> CLI project list (TUI separate) WS-CLI-001-44 native CLI release proof + gated manual publisher (not yet published) + WS-CLI-001-48 existing typed public reads -> opt-in read-only contributor TUI + exact-grant projects -> Ready / active Mine / own Submissions -> fresh detail + explicit Mine context/intake; findings/Evidence -> manual CLI handoff + later Evidence needs typed single-page port; writes need per-operation recovery task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff ARCH-04B hidden verified Submission input @@ -1427,6 +1450,12 @@ and award history never changes. ## Remaining Release Gates +Client scope remains distinct from product completion: the local read-only TUI +does not complete human review, payment or hosted proof. In-TUI evidence and +mutations are separate optional interface boundaries, not new backend lifecycle +prerequisites. CLI binary distribution still requires protected publication and +independent public-asset/install verification. + v0.1 is not ready until all of the following are true: - Reproduce the connected guide-to-contribution journey with the intended diff --git a/scripts/test_lightweight_agent_gates.py b/scripts/test_lightweight_agent_gates.py index 63bff3cf6..2a58f70e8 100644 --- a/scripts/test_lightweight_agent_gates.py +++ b/scripts/test_lightweight_agent_gates.py @@ -133,6 +133,7 @@ def test_cli_full_contract_preserves_failure_and_timeout_exits(self) -> None: workflow = Path(".github/workflows/cli.yml").read_text(encoding="utf-8") names = ( "Exercise built CLI against task HTTP and local file boundaries", + "Exercise native terminal HTTP and proof contracts", "Exercise existing guide HTTP contracts", "Exercise existing native guide API callers", "Exercise existing native CLI self-service caller", @@ -148,6 +149,11 @@ def test_cli_full_contract_preserves_failure_and_timeout_exits(self) -> None: script, ) self.assertNotIn("|| true", script) + if name == "Exercise existing guide HTTP contracts": + self.assertIn( + "guide_files+=(../cli/tests/integration/test_task_guide_http.py)", + script, + ) scripts.append(script) for exit_code in (0, 1, 124): with self.subTest(step=name, exit_code=exit_code): @@ -181,6 +187,16 @@ def test_cli_full_contract_preserves_failure_and_timeout_exits(self) -> None: "--ignore-glob=../cli/tests/integration/*_public_api.py", "--ignore-glob=../cli/tests/integration/test_guide*.py", "--ignore=../cli/tests/integration/test_public_self_service.py", + "--ignore=../cli/tests/integration/test_task_guide_http.py", + "--ignore=../cli/tests/integration/test_tui_http.py", + "--ignore=../cli/tests/integration/test_tui_native_proof.py", + ], + [ + *runner_prefix, + "--metadata-json", "${RUNNER_TEMP}/workstream-cli-tui-isolation.json", + *pytest_prefix, + "../cli/tests/integration/test_tui_http.py", + "../cli/tests/integration/test_tui_native_proof.py", ], [ *runner_prefix, @@ -212,6 +228,48 @@ def test_cli_full_contract_preserves_failure_and_timeout_exits(self) -> None: self.assertNotIn("continue-on-error", workflow) self.assertIn("timeout-minutes: 15", workflow) + def test_cli_contract_partitions_assign_every_group_once(self) -> None: + workflow = Path(".github/workflows/cli.yml").read_text(encoding="utf-8") + job = workflow.split("\n cli-public-contract:\n", 1)[1] + self.assertEqual( + job.split(" strategy:\n", 1)[1].split(" services:\n", 1)[0], + " fail-fast: false\n matrix:\n" + " group: [http, api]\n", + ) + self.assertEqual(job.count(" strategy:\n"), 1) + self.assertEqual(job.count(" group:"), 1) + self.assertNotIn("continue-on-error", job) + self.assertNotIn("\n if:", job) + groups = { + "Exercise built CLI against task HTTP and local file boundaries": "http", + "Exercise native terminal HTTP and proof contracts": "http", + "Exercise existing guide HTTP contracts": "http", + "Exercise existing native guide API callers": "api", + "Exercise existing native CLI self-service caller": "api", + "Exercise complete native CLI batch journey": "api", + } + steps = job.split("\n - ")[1:] + assignments = {"http": [], "api": []} + for step in steps: + title = step.splitlines()[0] + conditions = re.findall(r"^ if: (.+)$", step, re.MULTILINE) + name = title.removeprefix("name: ") + if name in groups: + group = groups[name] + self.assertEqual(conditions, ["${{ matrix.group == '" + group + "' }}"]) + assignments[group].append(name) + elif name == "Stop owned CLI artifact provider": + self.assertEqual(conditions, ["${{ always() }}"]) + else: + # Checkout, source/build/lint and provider custody run in both jobs. + self.assertEqual(conditions, []) + for group in assignments: + self.assertCountEqual( + assignments[group], [name for name, owner in groups.items() if owner == group], + ) + self.assertEqual(job.count("scripts/run_isolated_tests.py"), len(groups)) + self.assertIn(" timeout-minutes: 15\n", job) + def test_cli_backend_groups_use_the_frozen_backend_lock_environment(self) -> None: workflow = Path(".github/workflows/cli.yml").read_text(encoding="utf-8") install = workflow.split(" - name: Install API integration tooling\n", 1)[1].split( @@ -240,11 +298,11 @@ def test_cli_backend_groups_use_the_frozen_backend_lock_environment(self) -> Non ) self.assertEqual( workflow.count("uv run --frozen python scripts/run_isolated_tests.py"), - 5, + 6, ) self.assertEqual( workflow.count("-- uv run --frozen python -m pytest -q"), - 5, + 6, ) self.assertNotIn('python -m pip install -e "./backend[dev,agents]"', workflow) self.assertNotIn("\n ruff check cli/tests", workflow)