diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 73a3f5dc3..265cb518e 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -11,7 +11,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 | 108 tools through WS-MCP-002-12 cover access, guide setup, contributor workspace, contribution policies, checker/artifact operations and task execution/submissions; task operations/imports 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 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. First publication and public-asset audit remain operational work. -46 adds the separate system-reader project catalogue; -48 adds contributor TUI navigation and -49 adds findings pages and confirmed verified guide downloads. Evidence remains a manual CLI handoff; TUI product writes/management, public human-review controls and hosted proof 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. First publication and public-asset audit remain operational work. -46 adds the separate system-reader project catalogue; -48/-49 add contributor navigation, findings and verified guide downloads; -50 establishes the reading workbench. -51 bounds confirmed Claim/Start and direct collection navigation. TUI ZIP preparation/submission, automatic crash-safe recovery, contribution evidence, management, public human-review controls and hosted proof remain 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 8cc838d39..365246421 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -66,10 +66,40 @@ ordered locked-guide selection with explicit verified single-document download. [WS-CLI-001-50](WS-CLI-001-50.md) replaces their contributor presentation with visible collections/sections, readable instructions and truthful page/evidence states; it changes no public operation or lifecycle authority. -Contribution evidence remains an inert CLI handoff. Product mutations, management -screens and operation-specific recovery remain separate TUI boundaries; this +Contribution evidence remains an inert CLI handoff. Claim/start is bounded by +-51 below; other product mutations, management screens and their recovery remain +separate TUI boundaries; this interface does not depend on first binary publication or system-wide discovery. +## Connected contributor workbench + +The human's post-merge evaluation of -50 found that the read-only workbench was +not a usable complete workflow and collection/detail navigation was difficult. +Presentation proof is not completion of the contributor journey. Continue in +the existing TUI and typed API client, not a second client or simulated demo. + +1. [WS-CLI-001-51](WS-CLI-001-51.md): direct collection navigation and deliberate + claim/start using existing public writes, exact confirmation, caller-owned + replay keys and uncertain-outcome recovery. This is the current bounded work. +2. ZIP preparation: explicit local package selection, summary/attestation, + actual intake feedback and exact replay. Preparation is not Submission. +3. Submission and continuation: deliberate immutable creation, current outcome, + checker remediation and only API-supported human revision responses. No + automatic acceptance, hidden mutation route or invented reviewer workflow. +4. Contribution evidence: inspect the contributor's returned records and awards + inside the workbench, retaining the distinction from fulfillment. + +Only the first boundary is specified for implementation now. Each following +boundary needs inspected API/replay contracts, its own bounded record and native +public-API proof before starting. A durable automatic recovery design is needed +before replacing caller-owned saved keys with generated keys; the current CLI +contract must not become an unsafe in-memory TUI retry token. + +Risk is L1 for mutation, cancellation, disclosure and local artifact handling. +Preserve backend authority, locked lineage, immutable evidence and atomicity. +No new API is required for claim/start. Backend gaps discovered for subsequent +steps must be named and independently implemented, never hidden by a UI mock. + ## Current boundary [WS-CLI-001-26](WS-CLI-001-26.md) adds deliberate draft-guide summary editing @@ -78,7 +108,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 broader TUI beyond -49 remain separate work. Binary distribution's +client gaps and the broader TUI beyond -51 remain separate work. Binary distribution's workflow is prepared by -44; a protected release run and public-asset audit remain operational work. diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md new file mode 100644 index 000000000..b38f1c941 --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md @@ -0,0 +1,128 @@ +# WS-CLI-001-51 — Claim and start from the contributor workbench + +- Initiative: `WS-CLI-001` +- Durable disposition: `Planned` +- Intended merge outcome: Let a contributor deliberately claim and start exact tasks through the existing public API, with discoverable navigation and honest uncertain-outcome recovery. + +## Intent + +The merged -50 workbench is an observation interface, not a complete contributor +journey. The human found navigation difficult and missing actions made the UI +feel temporary. Main `f16dfe055` exposes working `api.Client.ClaimTask` and +`StartTask` and explicit CLI commands, but TUI `ReadClient` and `Model.key` do not +expose them. No backend prerequisite is needed. The existing real-API TUI drill +proves reads and fresh revocation only; it must not be relabelled mutation proof. + +## Bounded change + +Allowed: `cli/internal/tui/`, TUI wiring in `cli/internal/command/tui.go` and its +affected tests, focused existing CLI native HTTP/public-API tests and an opt-in +POST capture hook in their existing socket fixture, CLI README, +the single terminal design document, affected roadmap/initiative/index claims. +Reuse the existing typed mutation client, sanitizer, response validation and +backend authorization. No backend/client transport/CI/dependency changes. + +Not allowed: upload/Submission/revision/Review/Finance mutations, automatic +chained actions, polling, credentials/history storage, generic operation journal, +shell execution, compatibility paths, new auth rules or weakening tests. + +## Design and decisions + +- Add visible `a actions` on freshly inspected Ready task or Mine Overview. + Offer only Claim for observed ready work, Start for observed claimed Mine. + Observed status determines UI relevance, never server authority. + Gate both on exact freshly fetched `ContributorTaskDetail`, not row status; + Mine rows carry assignment status (`active`), not task status (`claimed`). +- Require a caller-owned UUID replay key saved outside this session, matching + the explicit CLI contract. Explain this requirement before entry. Do not + generate a transient key or imply crash recovery exists. Reason is omitted + consistently in this slice (JSON `{}` through existing client). + The UI cannot verify external saving: require user input and an explicit + save-warning acknowledgment at confirmation, with no generated fallback. +- Confirm exact action, actor, project, task, key and available locked terms; + Enter in key entry only advances to confirmation. Only a deliberate `y` at a + usable terminal dispatches one POST. No request on selection or resize. +- Freeze the packet while sending. Navigation/refresh cannot replace it or + dispatch another action. Quit cancels the client request, not server effects. + Unknown, malformed success and transport cancellation never mean rollback. +- Preserve unknown packet in the session, with explicit same-packet replay and + inert CLI recovery arguments. No automatically retried POST; no new key after + uncertainty. Session loss clears protected work; retained recovery references + must not expose previously cleared descriptions/terms or bearer. +- Confirmed receipt and subsequent read are separate outcomes. Do not erase a + successful claim/start because a refresh fails; do not automatically start + after claim. Reauthorize through existing API on every write/replay. +- Use a separate write outcome handler, not `failed()`'s read-page reset. + Bind returned task/project to the frozen packet and claim assignment contributor + to the bound actor; the existing typed decoder does not check the latter two + against the caller's selection. Any mismatch is unknown, never success. + Complete canonical 4xx is a known denial of that request; transport, cancellation + and malformed responses remain unknown. A denied replay cannot retroactively + prove an earlier unknown request failed. Keep unknown history until a validated + receipt resolves it. On 401/403/concealed 404, clear protected descriptions and + terms, retaining only minimal inert replay references; session failure disables + replay in this session. Pending writes consume navigation/refresh/action keys. + Quit cancels the request and exits, never claims server rollback; the caller's + externally saved key is the restart recovery contract, not an in-memory journal. +- Add visible direct collection shortcuts `1 Ready`, `2 Mine`, `3 Submissions` + from ordinary list/detail views and reverse Tab navigation. Text entry, + confirmations, pending operations and overlays must consume their own keys. + Preserve current keys and exact-resource cancellation semantics. + +The caller-owned key is an explicit initial limitation, not the desired final +consumer UX. Automatic crash-safe operation recovery needs a separately bounded +private-storage design; do not hide it in this action slice. + +## Acceptance criteria + +- Native TUI can inspect Ready, confirm claim, see receipt, inspect Mine, then + separately confirm start. Exact assignment/locked lineage remains backend-owned. +- Decline, resize below minimum, repeated keys, read movement and text entry + cannot cause a write. Different task/project/actor cannot receive a late result. +- Lost response retains original action/key and only explicit replay reuses it. + Read failure does not manufacture rejection, rollback or confirmed success. +- Real signed public API/PostgreSQL native drill proves claim/start, exact-key + replay, ownership and fresh revoked-authority rejection; preserve old GET-only + drill. HTTP tests prove exact wire/no automatic retry and corrupt receipt. + Use the separate ready task from `create_task`, not `_prepared_packet`'s already + started task. Prove claim replay before start, then start replay before revoke: + claim replay after progression may validly return state-conflict. Assert + caller/project substitution is unknown and that a successful write plus failed + read still shows the confirmed write receipt without restoring protected text. +- Go build/test/vet/race, native HTTP tests, layout/hostile text/download guards, + links, wording and full hosted proof remain required. Add a discriminating + hidden-confirmation/duplicate-dispatch regression; do not weaken old assertions. + +## Risk and review routing + +L1: contributor mutation, uncertainty and untrusted terminal content. Plan review +before implementation; focused security, product/operations, architecture/reuse, +QA/test-delta, CI integrity (shared native socket fixture) and docs review on a +frozen candidate. Human focus: actions are +obvious, claim and start remain separate, exact confirmation and uncertain-result +copy are understandable. No merge without specific human approval. + +## Evidence + +Focused model regressions exercise confirmation, frozen request identity, +substituted receipts, denied replay, protected clearing, bounded paste and +cross-resource navigation. Native HTTP tests exercise the built binary and +exact POST bytes. The separate signed public-API drill exercises claim and +start, same-key replay and live grant revocation against isolated PostgreSQL; +the earlier GET-only drill remains unchanged. These are distinct proof +boundaries, not evidence of the remaining submission journey. Exact-head +execution and reviewer results belong in the PR trust summary. + +Independent review exposed a late-read/session-denial interleaving: opening +replay while a refresh was pending could leave confirmation actionable after +the read blocked the session. Dispatch must recheck session and replay +eligibility, not rely solely on the earlier modal entry check. A focused +regression must reject the old dispatch behavior. Same-collection shortcuts +from detail must also return to the list rather than silently doing nothing. + +## Reconciliation and remaining journey + +This chunk closes Ready -> claimed -> in_progress inside the TUI. Subsequent +bounded work: ZIP preparation with feedback and safe replay; immutable submission +and outcome/revision navigation; contributor evidence and awards. Those are not +delivered by this chunk. Human Review controls require their own public API proof. diff --git a/cli/README.md b/cli/README.md index 9a0b77524..990bbcae9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -46,16 +46,35 @@ terms remain available separately. Workstream rechecks each public read. |---|---| | Arrows / Enter | Select a row or scroll detail; Enter inspects freshly | | Tab / Esc | Change section / go back or cancel | +| Shift+Tab | Previous collection or detail section | +| `1` / `2` / `3` | Go directly to Ready / Mine / Submissions from ordinary views | +| `a` in task Overview | Open Claim or Start when the freshly inspected task supports that action | | `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 | | `d` in Guide | Enter an existing download directory; Enter reviews the scrollable exact target, `y` confirms | -| `q` / Ctrl+C | Quit and cancel outstanding client reads; `q` is text during text entry, Ctrl+C always quits | - -The workspace makes no product mutation requests: it does not claim/start work, -prepare or submit ZIPs, export records, poll, acquire tokens or save local -history. Guide metadata is not proof of downloaded-byte integrity. In Guide, +| `q` / Ctrl+C | Quit and cancel outstanding client requests, not committed server effects; `q` is text during text entry, Ctrl+C always quits | + +Claim and Start are separate deliberate actions through the existing public +API. Inspect an exact Ready task to claim it; inspect a claimed Mine task to +start it. Supply a UUID key you have saved outside this session, review the +exact action/actor/project/task/key, press `s` to acknowledge the save warning, +then `y` to confirm. Enter alone never sends the action. The workbench does not generate or persist these keys. Save the +action, task ID and key together for recovery; this slice omits the optional +reason. A read or displayed action is not write authority. + +There is no automatic retry or claim-to-start chain. A lost response means +unknown outcome, not rollback. Explicit replay uses the unchanged action, task +and key (`p`, then review and acknowledge with `s` and confirm with `y`); +a later denial cannot prove the earlier request failed. Quit cancels +the client request, not any committed server effect. After restarting, use the +explicit CLI with your saved inputs and the same API origin and actor. Do not +substitute a fresh key or infer success from a refreshed list. Automatic +crash-safe recovery is not implemented. + +The workspace does not prepare or submit ZIPs, export records, poll, acquire +tokens or save local history. Guide metadata is not proof of downloaded-byte integrity. In Guide, select one document, enter an existing real directory and confirm its label, document/task IDs, path, UUID filename, byte count and hash. Verified bytes are published under that filename without overwriting an existing file. Cancelled @@ -72,11 +91,12 @@ automatically. A denial clears affected content; expired authentication clears the session's work data. A failed current read clears its content and continuation and is shown as unavailable, never as an empty queue or successful operation. Refresh -is deliberate. No background request retries or write recovery occur. The +is deliberate. A confirmed write receipt remains distinct from a failed refresh; +minimal inert replay references do not restore protected task content. 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 future write/management workflows. See the [terminal presentation contract](../docs/design/cli-terminal-experience.md). +admission, last-seen and audit effects. This local interface does not establish +a deployed service, payment or complete submission/management workflows. See +the [terminal presentation contract](../docs/design/cli-terminal-experience.md). ## Explicit commands @@ -375,8 +395,9 @@ and contribution evidence with PostgreSQL, MinIO and Celery; see [the journey evidence](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-43.md). That proof is not hosted deployment verification or proof of human review. The [capability ledger](../docs/roadmap_status.md) identifies those remaining -product boundaries. The interactive workspace above is still read-only; -explicit commands remain the interface for supported mutations. +product boundaries. The interactive workspace exposes claim/start alongside +contributor reads and guide downloads; explicit commands remain the interface +for preparation, submission and the other supported mutations. ### Download and install diff --git a/cli/internal/command/tui.go b/cli/internal/command/tui.go index 725acf01e..c31dc18d4 100644 --- a/cli/internal/command/tui.go +++ b/cli/internal/command/tui.go @@ -16,7 +16,7 @@ import ( 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, + cmd := &cobra.Command{Use: "tui", Short: "Open the opt-in 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"} @@ -36,7 +36,7 @@ func addTUI(root *cobra.Command, client func() (*api.Client, error), output *str root.AddCommand(cmd) } -func runTUI(ctx context.Context, client tui.ReadClient, input io.Reader, output io.Writer, getenv environment, plain bool) (err error) { +func runTUI(ctx context.Context, client tui.Client, 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} diff --git a/cli/internal/tui/actions.go b/cli/internal/tui/actions.go new file mode 100644 index 000000000..c2c29d5b4 --- /dev/null +++ b/cli/internal/tui/actions.go @@ -0,0 +1,393 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +type writeMode int + +const ( + writeIdle writeMode = iota + writeKeyEntry + writeConfirm + writePending + writeOutcome +) + +type writeOutcomeKind int + +const ( + outcomeNone writeOutcomeKind = iota + outcomeConfirmed + outcomeDenied + outcomeUnknown +) + +type writePacket struct { + action, actor, project, task, key string +} + +type writeState struct { + mode writeMode + packet writePacket + outcome writeOutcomeKind + keyInput string + notice string + acknowledged bool + replay bool + replayDisabled bool + sequence uint64 +} + +type writeResponse struct { + packet writePacket + generation uint64 + sequence uint64 + value any + err error +} + +var replayUUID = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +var replayUUIDCharacters = regexp.MustCompile(`(?i)^[0-9a-f-]+$`) + +func (m *Model) paste(value string) { + if m.closed || m.tooSmall() || m.write.mode != writeKeyEntry { + return + } + if len(value) == 0 || len(m.write.keyInput)+len(value) > 100 || !replayUUIDCharacters.MatchString(value) { + m.write.notice = "Paste one UUID without spaces or control characters. No action was sent." + return + } + m.write.keyInput += value + m.write.notice = "" +} + +func (m *Model) actionName() string { + if m.tab == ready && m.task != nil && m.task.Status == "ready" { + return "Claim" + } + if m.tab == mine && m.section == 0 && m.task != nil && m.task.Status == "claimed" { + return "Start" + } + return "" +} + +func (m *Model) canOpenAction() bool { + selected, ok := m.selectedRow() + return m.detail && !m.loading && m.failure == "" && m.section == 0 && + m.write.outcome != outcomeUnknown && m.actionName() != "" && ok && + !(m.write.outcome == outcomeConfirmed && sameID(m.write.packet.task, m.task.TaskID) && m.write.packet.action == strings.ToLower(m.actionName())) && + sameID(selected.task, m.task.TaskID) && sameID(selected.project, m.task.ProjectID) && + sameID(m.project, m.task.ProjectID) && m.actor != "" +} + +func (m *Model) openAction() { + action := strings.ToLower(m.actionName()) + m.write = writeState{mode: writeKeyEntry, packet: writePacket{ + action: action, actor: m.actor, project: m.project, task: m.task.TaskID, + }} + m.viewport.GotoTop() +} + +func (m *Model) canReplay() bool { + return !m.closed && !m.blocked && !m.loading && !m.write.replayDisabled && m.write.packet.key != "" && + (m.write.outcome == outcomeUnknown || m.write.outcome == outcomeConfirmed) +} + +func (m *Model) writeKey(key tea.KeyPressMsg) tea.Cmd { + k := key.String() + switch m.write.mode { + case writeKeyEntry: + switch k { + case "esc": + m.write = writeState{} + case "enter": + if replayUUID.MatchString(m.write.keyInput) { + m.write.packet.key = m.write.keyInput + m.write.keyInput, m.write.notice = "", "" + m.write.mode = writeConfirm + } else { + m.write.notice = "Enter a caller-owned UUID saved outside this session." + } + case "backspace": + _, size := utf8.DecodeLastRuneInString(m.write.keyInput) + if size > 0 { + m.write.keyInput = m.write.keyInput[:len(m.write.keyInput)-size] + } + default: + if key.Text != "" && len(m.write.keyInput)+len(key.Text) <= 100 { + m.write.keyInput += key.Text + } + } + case writeConfirm: + switch k { + case "esc": + if m.write.replay { + m.write.mode = writeOutcome + m.write.replay = false + } else { + m.write = writeState{} + } + case "s": + m.write.acknowledged = true + case "y": + if m.write.acknowledged && !m.tooSmall() { + return m.beginWrite() + } + default: + return m.scroll(k) + } + case writeOutcome: + switch k { + case "p": + if m.canReplay() { + m.write.mode, m.write.acknowledged, m.write.replay = writeConfirm, false, true + } + case "r": + if !m.blocked { + m.write.mode = writeIdle + if m.detail { + m.task, m.context, m.intake, m.submission = nil, nil, nil, nil + return m.begin(m.detailKind()) + } + m.clearPage() + return m.begin(pageRead) + } + case "esc": + m.write.mode = writeIdle + default: + return m.scroll(k) + } + } + return nil +} + +func (m *Model) beginWrite() tea.Cmd { + packet := m.write.packet + if m.closed || m.ctx.Err() != nil || m.blocked || m.write.replayDisabled || m.loading || m.tooSmall() || + m.write.mode != writeConfirm || !m.write.acknowledged || + (packet.action != "claim" && packet.action != "start") || + !replayUUID.MatchString(packet.key) || !sameID(packet.actor, m.actor) { + if m.write.mode == writeConfirm { + m.write.mode, m.write.acknowledged, m.write.replay = writeOutcome, false, false + m.write.notice = "Not sent: confirmation expired after read or session state changed. No POST was dispatched." + } + return nil + } + m.invalidate() + m.write.mode, m.write.sequence = writePending, m.write.sequence+1 + m.write.notice = "" + generation, sequence := m.generation, m.write.sequence + ctx, cancel := context.WithCancel(m.ctx) + m.cancel = cancel + client := m.client + return func() (message tea.Msg) { + if ctx.Err() != nil { + return writeResponse{packet: packet, generation: generation, sequence: sequence, + err: &api.Failure{Code: "request_failed", OutcomeUnknown: true}} + } + defer func() { + if recover() != nil { + message = writeResponse{packet: packet, generation: generation, sequence: sequence, + err: &api.Failure{Code: "request_failed", OutcomeUnknown: true}} + } + }() + var value any + var err error + if packet.action == "claim" { + var result api.Result[api.ClaimedTask] + result, err = client.ClaimTask(ctx, packet.task, packet.key, nil) + value = result.Value + } else { + var result api.Result[api.MutationTask] + result, err = client.StartTask(ctx, packet.task, packet.key, nil) + value = result.Value + } + return writeResponse{packet: packet, generation: generation, sequence: sequence, value: value, err: err} + } +} + +func (m *Model) receiveWrite(msg writeResponse) { + if m.write.mode != writePending || msg.packet != m.write.packet || + msg.generation != m.generation || msg.sequence != m.write.sequence { + return + } + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + m.write.mode = writeOutcome + m.write.acknowledged = false + packet := msg.packet + if msg.err == nil { + valid := false + switch v := msg.value.(type) { + case api.ClaimedTask: + valid = packet.action == "claim" && sameID(v.Task.ID, packet.task) && + sameID(v.Task.ProjectID, packet.project) && v.Task.Status == "claimed" && + sameID(v.Assignment.TaskID, packet.task) && sameID(v.Assignment.ProjectID, packet.project) && + sameID(v.Assignment.ContributorID, packet.actor) + case api.MutationTask: + valid = packet.action == "start" && sameID(v.ID, packet.task) && + sameID(v.ProjectID, packet.project) && v.Status == "in_progress" + } + if valid { + m.write.outcome, m.write.notice = outcomeConfirmed, "Validated receipt. A later read is a separate observation." + m.observed = time.Now() + m.viewport.GotoTop() + return + } + msg.err = &api.Failure{Code: "invalid_api_response", OutcomeUnknown: true} + } + var failure *api.Failure + knownDenial := errors.As(msg.err, &failure) && !failure.OutcomeUnknown && failure.Status >= 400 && failure.Status < 500 + if knownDenial { + if m.write.outcome == outcomeNone { + m.write.outcome = outcomeDenied + } + m.write.notice = "Request denied: " + m.clean(failure.Code) + fmt.Sprintf(" (HTTP %d).", failure.Status) + if m.write.outcome == outcomeUnknown { + m.write.notice += " Earlier outcome remains unknown." + } + if m.write.outcome == outcomeConfirmed { + m.write.notice += " Earlier validated receipt remains confirmed." + } + } else { + if m.write.outcome == outcomeConfirmed { + m.write.notice = "Replay response unknown; earlier validated receipt remains confirmed." + } else { + m.write.outcome = outcomeUnknown + m.write.notice = "Outcome unknown: response was not validated. Do not use a new key for this action." + } + } + if errors.As(msg.err, &failure) && (failure.Status == 401 || failure.Status == 403 || failure.Status == 404) { + m.clearPage() + m.projectName = "" + m.write.replayDisabled = true + if failure.Status == 401 { + m.blocked = true + m.actor, m.project = "", "" + } + } + m.viewport.GotoTop() +} + +func (m *Model) writeText() string { + w := m.write + p := w.packet + d := detailWriter{m: m} + switch w.mode { + case writeKeyEntry: + d.heading("Caller-owned replay key") + d.note("Save a UUID outside this session before continuing. This workbench does not generate or persist a recovery key.") + d.line("Action", strings.Title(p.action)) + d.line("Task", p.task) + d.line("UUID", w.keyInput) + d.note("Enter reviews; Esc cancels. No write has been sent.") + case writeConfirm: + d.heading("Confirm " + p.action) + d.line("Action", strings.Title(p.action)) + d.line("Actor", p.actor) + d.line("Project", p.project) + d.line("Task", p.task) + d.line("Replay key", p.key) + if m.task != nil && sameID(m.task.TaskID, p.task) && sameID(m.task.ProjectID, p.project) { + d.line("Contribution policy version", m.task.Compensation.ContributionPolicyVersionID) + for _, term := range []struct { + label string + raw []byte + }{{"Accepted submission terms", m.task.Compensation.AcceptedSubmission}, {"Completed review terms", m.task.Compensation.CompletedReview}} { + awards, ok := api.CompensationAwards(term.raw) + if !ok { + d.line(term.label, "unavailable") + } else if len(awards) == 0 { + d.line(term.label, "unpaid") + } else { + for _, award := range awards { + d.line(term.label, award.Instrument+" / "+award.Unit+" / "+award.Quantity) + } + } + } + } else { + d.note("Locked terms are not retained here; inspect fresh task detail before relying on them.") + } + if w.replay { + if w.outcome == outcomeUnknown { + d.note("Replay uses the exact prior action, actor, project, task and key. Earlier uncertainty remains until a validated receipt.") + } else { + d.note("Replay uses the exact prior action, actor, project, task and key. The prior validated receipt remains confirmed.") + } + } + d.note("Save this key outside the session. The workbench cannot verify your copy.") + if w.acknowledged { + d.note("Save warning acknowledged. Press y to send one POST; Esc cancels.") + } else { + d.note("Press s to acknowledge saving, then y to send one POST. Esc cancels.") + } + case writePending: + d.heading("Sending " + p.action) + d.line("Task", p.task) + d.line("Replay key", p.key) + d.note("One POST is pending. Quit cancels the client request, not possible server effects.") + case writeOutcome: + d.note(m.writeSummary()) + d.line("Action", p.action) + d.line("Actor", p.actor) + d.line("Project", p.project) + d.line("Task", p.task) + d.line("Replay key", p.key) + d.note(m.clean(w.notice)) + d.note(m.clean("Recovery CLI arguments: workstream task " + p.action + " " + p.task + " --idempotency-key " + p.key)) + if m.canReplay() { + d.note("p reviews explicit same-packet replay; no automatic retry.") + } + if m.blocked { + d.note("Session unavailable; replay and reads are disabled here. Quit and use the externally saved key for recovery.") + } else { + d.note("r separately refreshes the read; Esc returns. A read cannot prove rollback.") + } + } + if w.notice != "" && w.mode == writeKeyEntry { + d.note(m.clean(w.notice)) + } + return d.String() +} + +func (m *Model) writeSummary() string { + switch m.write.outcome { + case outcomeNone: + return "Not sent" + case outcomeConfirmed: + if m.write.packet.action == "claim" { + return "Claim confirmed" + } + return "Start confirmed" + case outcomeDenied: + return "Request denied" + case outcomeUnknown: + return "Outcome unknown" + } + return "" +} + +func (m *Model) writeBanner() string { + if m.write.outcome != outcomeConfirmed && m.write.outcome != outcomeUnknown { + return "" + } + p := m.write.packet + guidance := "p reviews its exact packet." + if !m.canReplay() { + guidance = "In-session replay unavailable; use saved inputs with the CLI after authority is restored." + } + return "Prior operation: " + m.writeSummary() + " | task " + m.clean(p.task) + + " | project " + m.clean(p.project) + ". " + guidance + "\n\n" +} diff --git a/cli/internal/tui/actions_test.go b/cli/internal/tui/actions_test.go new file mode 100644 index 000000000..f6766152a --- /dev/null +++ b/cli/internal/tui/actions_test.go @@ -0,0 +1,407 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +const replayKey = "80000000-0000-7000-8000-000000000001" + +func readyDetail(t *testing.T, f *fakeClient) *Model { + t.Helper() + m := boot(t, f) + press(t, m, "enter") + press(t, m, "enter") + if !m.canOpenAction() { + t.Fatal("fresh ready detail did not offer Claim") + } + return m +} + +func confirmClaim(t *testing.T, m *Model) { + t.Helper() + press(t, m, "a") + if m.write.mode != writeKeyEntry || !strings.Contains(m.View().Content, "Save a UUID outside this session") { + t.Fatal("action did not explain caller-owned recovery key") + } + press(t, m, replayKey) + press(t, m, "enter") + if m.write.mode != writeConfirm || !strings.Contains(m.View().Content, "Confirm claim") || + !strings.Contains(m.View().Content, "Actor: "+actorID) || + !strings.Contains(m.View().Content, "Project: "+projectID) || + !strings.Contains(m.View().Content, "Contribution policy version: "+policyID) { + t.Fatal("exact confirmation not visible", m.View().Content) + } +} + +func TestClaimRequiresVisibleAcknowledgedConfirmation(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + confirmClaim(t, m) + before := len(f.calls) + for _, key := range []string{"y", "1", "2", "3", "tab", "r", "a", "enter"} { + _, cmd := m.Update(keyMessage(key)) + if cmd != nil || len(f.calls) != before { + t.Fatalf("%q dispatched before save acknowledgment", key) + } + } + m.Update(tea.WindowSizeMsg{Width: 30, Height: 8}) + press(t, m, "s") + press(t, m, "y") + if m.write.acknowledged || len(f.calls) != before { + t.Fatal("hidden confirmation changed or dispatched") + } + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeConfirmed || strings.Count(strings.Join(f.calls, ","), "claim") != 1 { + t.Fatal("deliberate confirmation did not send exactly one POST", f.calls) + } + if !strings.Contains(m.View().Content, "Claim confirmed") { + t.Fatal(m.View().Content) + } +} + +func TestBracketedPasteIsOnlyKeyEntryTextNeverConfirmation(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + press(t, m, "a") + _, cmd := m.Update(tea.PasteMsg{Content: replayKey + "\ny"}) + if cmd != nil || m.write.keyInput != "" || !strings.Contains(m.View().Content, "Paste one UUID") { + t.Fatal("multiline paste was accepted or dispatched") + } + m.Update(tea.PasteMsg{Content: replayKey}) + if m.write.keyInput != replayKey || m.write.mode != writeKeyEntry { + t.Fatal("UUID paste was not inserted as text") + } + press(t, m, "enter") + before := len(f.calls) + m.Update(tea.PasteMsg{Content: "sy"}) + if m.write.acknowledged || len(f.calls) != before { + t.Fatal("confirmation paste became commands") + } + press(t, m, "y") + if len(f.calls) != before { + t.Fatal("pasted acknowledgment dispatched write") + } + press(t, m, "s") + m.Update(tea.PasteMsg{Content: "y"}) + if len(f.calls) != before { + t.Fatal("pasted confirmation dispatched write") + } + press(t, m, "y") + if strings.Count(strings.Join(f.calls, ","), "claim") != 1 { + t.Fatal("manual y did not dispatch exactly once") + } +} + +func TestWritePendingConsumesNavigationAndLateResultIsBound(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + confirmClaim(t, m) + press(t, m, "s") + _, cmd := m.Update(keyMessage("y")) + if cmd == nil || m.write.mode != writePending { + t.Fatal("write did not enter pending state") + } + before := len(f.calls) + for _, key := range []string{"y", "1", "2", "3", "tab", "shift+tab", "r", "g", "a", "p", "esc", "enter"} { + _, duplicate := m.Update(keyMessage(key)) + if duplicate != nil || m.tab != ready || m.write.mode != writePending || len(f.calls) != before { + t.Fatalf("%q moved or duplicated pending write", key) + } + } + // A valid typed receipt substituted from another project is never success. + f.claim = func(context.Context, string, string, *string) (api.Result[api.ClaimedTask], error) { + return api.Result[api.ClaimedTask]{Value: api.ClaimedTask{ + Task: api.MutationTask{ID: taskID, ProjectID: actorID, Status: "claimed"}, + Assignment: api.TaskAssignment{TaskID: taskID, ProjectID: actorID, ContributorID: actorID}, + }}, nil + } + apply(t, m, cmd) + if m.write.outcome != outcomeUnknown || !strings.Contains(m.View().Content, "Outcome unknown") { + t.Fatal("substituted project was accepted", m.View().Content) + } +} + +func TestClaimActorSubstitutionAndDeniedReplayKeepUncertainty(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + confirmClaim(t, m) + f.claim = func(_ context.Context, task, key string, reason *string) (api.Result[api.ClaimedTask], error) { + if key != replayKey || reason != nil { + t.Fatal("wire packet changed", key, reason) + } + if strings.Count(strings.Join(f.calls, ","), "claim") == 1 { + return api.Result[api.ClaimedTask]{Value: api.ClaimedTask{ + Task: api.MutationTask{ID: task, ProjectID: projectID, Status: "claimed"}, + Assignment: api.TaskAssignment{TaskID: task, ProjectID: projectID, ContributorID: projectID}, + }}, nil + } + return api.Result[api.ClaimedTask]{}, &api.Failure{Code: "state_conflict", Status: 409} + } + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeUnknown { + t.Fatal("wrong contributor accepted") + } + for _, key := range []string{"y", "enter", "r"} { + if key != "r" { + press(t, m, key) + } + } + if strings.Count(strings.Join(f.calls, ","), "claim") != 1 { + t.Fatal("automatic retry") + } + press(t, m, "p") + if m.write.mode != writeConfirm || !m.write.replay || m.write.packet.key != replayKey { + t.Fatal("exact replay not offered") + } + press(t, m, "y") + if strings.Count(strings.Join(f.calls, ","), "claim") != 1 { + t.Fatal("replay skipped save acknowledgment") + } + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeUnknown || !strings.Contains(m.View().Content, "Earlier outcome remains unknown") { + t.Fatal("denial retroactively erased uncertainty", m.View().Content) + } +} + +func TestConfirmedWriteSurvivesFailedReadAndExplicitReplay(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + confirmClaim(t, m) + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeConfirmed { + t.Fatal("no validated receipt") + } + press(t, m, "p") + if m.write.mode != writeConfirm || !m.write.replay { + t.Fatal("confirmed same-key replay unavailable") + } + press(t, m, "s") + press(t, m, "y") + if strings.Count(strings.Join(f.calls, ","), "claim") != 2 || m.write.outcome != outcomeConfirmed { + t.Fatal("confirmed replay failed", f.calls) + } + 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 m.task != nil || m.write.outcome != outcomeConfirmed || !strings.Contains(m.View().Content, "Prior operation: Claim confirmed | task "+taskID) || strings.Contains(m.View().Content, "Exact instructions") { + t.Fatal("failed read erased receipt or retained protected text", m.View().Content) + } +} + +func TestDelayedReadSessionDenialCannotDispatchReplay(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + confirmClaim(t, m) + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeConfirmed { + t.Fatal("fixture needs a validated receipt") + } + _, delayedRead := m.Update(keyMessage("r")) + if delayedRead == nil || !m.loading { + t.Fatal("fixture needs pending read") + } + press(t, m, "p") + f.task = func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) { + return api.Result[api.ContributorTaskDetail]{}, &api.Failure{Code: "session_unavailable", Status: 401} + } + apply(t, m, delayedRead) + before := strings.Count(strings.Join(f.calls, ","), "claim") + press(t, m, "s") + press(t, m, "y") + if after := strings.Count(strings.Join(f.calls, ","), "claim"); after != before || !m.blocked || m.task != nil || m.write.outcome != outcomeConfirmed || m.write.mode == writeConfirm || + !strings.Contains(m.View().Content, "Denied: session_unavailable") || !strings.Contains(m.View().Content, "Prior operation: Claim confirmed") { + t.Fatalf("delayed read denial dispatched stale replay: claims %d -> %d, blocked=%v, outcome=%v", before, after, m.blocked, m.write.outcome) + } + // Even a stale caller of the owner operation cannot bypass the session guard. + m.write.mode, m.write.acknowledged = writeConfirm, true + if cmd := m.beginWrite(); cmd != nil || strings.Count(strings.Join(f.calls, ","), "claim") != before { + t.Fatal("dispatch boundary ignored denied session") + } +} + +func TestTransientReadFailureDoesNotDisableExplicitReplay(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + confirmClaim(t, m) + press(t, m, "s") + press(t, m, "y") + f.task = func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) { + return api.Result[api.ContributorTaskDetail]{}, errors.New("temporary transport failure") + } + press(t, m, "r") + if !m.canReplay() || m.write.outcome != outcomeConfirmed { + t.Fatal("transient read failure disabled exact replay") + } + press(t, m, "p") + if m.write.mode != writeConfirm || m.write.packet.key != replayKey { + t.Fatal("transient failure lost packet") + } +} + +func TestPriorReceiptIsBoundWhileBrowsingDifferentProject(t *testing.T) { + otherProject := "20000000-0000-7000-8000-000000000002" + f := &fakeClient{projects: func(context.Context, api.PageQuery) (api.Result[api.ActorProjectPage], error) { + return api.Result[api.ActorProjectPage]{Value: api.ActorProjectPage{Items: []api.ActorProject{ + {ProjectID: projectID, Name: "Original", Status: "active"}, + {ProjectID: otherProject, Name: "Other", Status: "active"}, + }}}, nil + }} + m := readyDetail(t, f) + confirmClaim(t, m) + press(t, m, "s") + press(t, m, "y") + press(t, m, "esc") + press(t, m, "g") + press(t, m, "down") + press(t, m, "enter") + if m.project != otherProject || !strings.Contains(m.body(), "Prior operation: Claim confirmed | task "+taskID+" | project "+projectID) { + t.Fatal("prior receipt attributed to new project", m.View().Content) + } + press(t, m, "p") + if m.write.mode != writeConfirm || m.write.packet.project != projectID || m.write.packet.key != replayKey { + t.Fatal("navigation changed recovery packet") + } +} + +func TestUnknownSessionFailureClearsProtectedContentAndDisablesReplay(t *testing.T) { + f := &fakeClient{} + m := readyDetail(t, f) + confirmClaim(t, m) + f.claim = func(context.Context, string, string, *string) (api.Result[api.ClaimedTask], error) { + return api.Result[api.ClaimedTask]{}, &api.Failure{Code: "session_unavailable", Status: 401, OutcomeUnknown: true} + } + press(t, m, "s") + press(t, m, "y") + if m.task != nil || !m.blocked || m.canReplay() || strings.Contains(m.View().Content, "Exact instructions") || strings.Contains(m.View().Content, "Accepted submission terms") { + t.Fatal("session failure retained protected content or replay", m.View().Content) + } + press(t, m, "p") + if m.write.mode != writeOutcome { + t.Fatal("blocked replay entered confirmation") + } +} + +func TestDirectCollectionsReverseTabAndTextEntryIsolation(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + press(t, m, "3") + if m.tab != submissions { + t.Fatal("3 did not select submissions") + } + press(t, m, "shift+tab") + if m.tab != mine { + t.Fatal("reverse Tab did not select Mine") + } + press(t, m, "1") + if m.tab != ready { + t.Fatal("1 did not select Ready") + } + press(t, m, "/") + press(t, m, "2") + if m.tab != ready || m.filter != "2" { + t.Fatal("text entry intercepted direct navigation") + } + press(t, m, "esc") + press(t, m, "r") + press(t, m, "enter") + press(t, m, "a") + press(t, m, "3") + if m.tab != ready || m.write.keyInput != "3" { + t.Fatal("key entry intercepted direct navigation") + } + press(t, m, "esc") + press(t, m, "c") + press(t, m, "2") + if m.tab != ready { + t.Fatal("handoff intercepted direct navigation") + } +} + +func TestDirectSameCollectionReturnsFromDetailToList(t *testing.T) { + for _, tc := range []struct { + key string + tab tab + read string + }{ + {"1", ready, "ready"}, {"2", mine, "mine"}, {"3", submissions, "submissions"}, + } { + t.Run(tc.read, func(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + press(t, m, tc.key) + press(t, m, "enter") + if !m.detail || m.tab != tc.tab { + t.Fatal("fixture did not inspect target collection") + } + before := strings.Count(strings.Join(f.calls, ","), tc.read) + press(t, m, tc.key) + if m.detail || m.tab != tc.tab || strings.Count(strings.Join(f.calls, ","), tc.read) != before+1 { + t.Fatal("same collection shortcut did not return to fresh list", f.calls) + } + press(t, m, tc.key) + if strings.Count(strings.Join(f.calls, ","), tc.read) != before+1 { + t.Fatal("same collection list key re-read", f.calls) + } + }) + } +} + +func TestWriteTransportErrorIsUnknown(t *testing.T) { + f := &fakeClient{claim: func(context.Context, string, string, *string) (api.Result[api.ClaimedTask], error) { + return api.Result[api.ClaimedTask]{}, errors.New("transport unavailable") + }} + m := readyDetail(t, f) + confirmClaim(t, m) + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeUnknown { + t.Fatal("transport error treated as denial") + } +} + +func TestMineStartUsesClaimedDetailNotActiveAssignmentRow(t *testing.T) { + f := &fakeClient{task: func(context.Context, string) (api.Result[api.ContributorTaskDetail], error) { + v := taskDetail() + v.Status = "claimed" + return api.Result[api.ContributorTaskDetail]{Value: v}, nil + }} + m := boot(t, f) + press(t, m, "enter") + press(t, m, "2") + if m.rows[0].status != "active" { + t.Fatal("fixture does not distinguish assignment and task status") + } + if m.canOpenAction() { + t.Fatal("assignment row alone offered Start") + } + press(t, m, "enter") + if !m.canOpenAction() || m.actionName() != "Start" { + t.Fatal("claimed detail did not offer Start") + } + press(t, m, "a") + press(t, m, replayKey) + press(t, m, "enter") + if !strings.Contains(m.View().Content, "Confirm start") { + t.Fatal("Start confirmation absent") + } + press(t, m, "s") + press(t, m, "y") + if m.write.outcome != outcomeConfirmed || !strings.Contains(m.View().Content, "Start confirmed") || strings.Count(strings.Join(f.calls, ","), "start") != 1 { + t.Fatal("Start was not an independent confirmed write", f.calls) + } +} diff --git a/cli/internal/tui/details.go b/cli/internal/tui/details.go index 95495046e..3dd484c71 100644 --- a/cli/internal/tui/details.go +++ b/cli/internal/tui/details.go @@ -124,7 +124,9 @@ func (m *Model) taskText() string { } } d.note("Terms are potential earned terms, not acceptance or fulfillment.") - d.note("No action can be sent from this workbench.") + if m.canOpenAction() { + d.note("a actions opens the deliberate " + m.actionName() + " flow; server authorization is checked again on write.") + } d.heading("References") d.line("Task", v.TaskID) d.line("Project", v.ProjectID) diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go index 70ea7f8cc..b0768a1b0 100644 --- a/cli/internal/tui/model.go +++ b/cli/internal/tui/model.go @@ -36,7 +36,7 @@ const ( // Model owns one fetched page and one exact detail, never an authority cache. type Model struct { - client ReadClient + client Client ctx context.Context stop context.CancelFunc cancel context.CancelFunc @@ -70,10 +70,11 @@ type Model struct { guideDirectory, guideNotice string download *guideDownload viewport viewport.Model + write writeState } // 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 { +func New(ctx context.Context, client Client, 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()} @@ -105,6 +106,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.backgroundKnown, m.darkBackground = true, msg.IsDark() case response: return m, m.receive(msg) + case writeResponse: + m.receiveWrite(msg) + return m, nil + case tea.PasteMsg: + m.paste(msg.Content) + return m, nil case tea.KeyPressMsg: return m, m.key(msg) } @@ -168,12 +175,21 @@ func (m *Model) selectedRow() (row, bool) { func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { k := key.String() - if k == "ctrl+c" || (!m.filtering && m.guideMode != guideDestination && k == "q") { + if k == "ctrl+c" || (!m.filtering && m.guideMode != guideDestination && m.write.mode != writeKeyEntry && k == "q") { m.closed = true m.invalidate() m.stop() return tea.Quit } + if m.write.mode == writePending { + return nil + } + if m.write.mode == writeKeyEntry || m.write.mode == writeConfirm || m.write.mode == writeOutcome { + if m.tooSmall() { + return nil + } + return m.writeKey(key) + } if m.filtering { switch k { case "enter": @@ -242,6 +258,14 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { if m.blocked || m.tooSmall() { return nil } + if k == "c" { + m.handoff = !m.handoff + m.viewport.GotoTop() + return nil + } + if m.handoff { + return m.scroll(k) + } if k == "g" && m.actor != "" { m.invalidate() m.clearPage() @@ -249,13 +273,23 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { m.project, m.projectName = "", "" return m.begin(projectsRead) } - if k == "c" { - m.handoff = !m.handoff - m.viewport.GotoTop() + if k == "p" && m.canReplay() { + m.write.mode, m.write.acknowledged, m.write.replay = writeConfirm, false, true return nil } - if m.handoff { - return m.scroll(k) + if k == "a" && m.canOpenAction() { + m.openAction() + return nil + } + if !m.picker && (k == "1" || k == "2" || k == "3") { + target := tab(int(k[0] - '1')) + if target != m.tab || m.detail { + m.invalidate() + m.clearPage() + m.tab = target + return m.begin(pageRead) + } + return nil } if k == "esc" && m.detail { if m.tab == submissions && m.section == 1 { @@ -270,10 +304,14 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { m.failure = "" return nil } - if k == "tab" && !m.picker && !m.detail { + if (k == "tab" || k == "shift+tab") && !m.picker && !m.detail { m.invalidate() m.clearPage() - m.tab = (m.tab + 1) % 3 + if k == "tab" { + m.tab = (m.tab + 1) % 3 + } else { + m.tab = (m.tab + 2) % 3 + } return m.begin(pageRead) } if k == "r" { @@ -299,8 +337,12 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { return nil } if m.detail { - if k == "tab" && m.tab == mine { - m.section = (m.section + 1) % 4 + if (k == "tab" || k == "shift+tab") && m.tab == mine { + if k == "tab" { + m.section = (m.section + 1) % 4 + } else { + m.section = (m.section + 3) % 4 + } m.viewport.GotoTop() switch m.section { case 1: @@ -311,7 +353,7 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { return m.begin(contextRead) } } - if k == "tab" && m.tab == submissions { + if (k == "tab" || k == "shift+tab") && m.tab == submissions { m.invalidate() m.section = (m.section + 1) % 2 m.viewport.GotoTop() diff --git a/cli/internal/tui/model_test.go b/cli/internal/tui/model_test.go index 3eb75787c..95b5098d2 100644 --- a/cli/internal/tui/model_test.go +++ b/cli/internal/tui/model_test.go @@ -31,6 +31,8 @@ type fakeClient struct { intake func(context.Context, string) (api.Result[api.SubmissionRequirements], error) findings func(context.Context, string, string, int, *string) (api.Result[api.SubmissionFindingsResult], error) download func(context.Context, string, api.TaskGuideDocument, io.Writer) error + claim func(context.Context, string, string, *string) (api.Result[api.ClaimedTask], error) + start func(context.Context, string, string, *string) (api.Result[api.MutationTask], error) assignmentQuery, submissionQuery api.PageQuery } @@ -110,6 +112,23 @@ func (f *fakeClient) DownloadGuideDocument(ctx context.Context, task string, doc _, err := target.Write([]byte("guide")) return err } +func (f *fakeClient) ClaimTask(ctx context.Context, task, key string, reason *string) (api.Result[api.ClaimedTask], error) { + f.calls = append(f.calls, "claim") + if f.claim != nil { + return f.claim(ctx, task, key, reason) + } + return api.Result[api.ClaimedTask]{Value: api.ClaimedTask{ + Task: api.MutationTask{ID: task, ProjectID: projectID, Status: "claimed"}, + Assignment: api.TaskAssignment{TaskID: task, ProjectID: projectID, ContributorID: actorID}, + }}, nil +} +func (f *fakeClient) StartTask(ctx context.Context, task, key string, reason *string) (api.Result[api.MutationTask], error) { + f.calls = append(f.calls, "start") + if f.start != nil { + return f.start(ctx, task, key, reason) + } + return api.Result[api.MutationTask]{Value: api.MutationTask{ID: task, ProjectID: projectID, Status: "in_progress"}}, nil +} func apply(t *testing.T, m *Model, cmd tea.Cmd) { t.Helper() @@ -133,6 +152,8 @@ func keyMessage(key string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyEscape} case "tab": return tea.KeyPressMsg{Code: tea.KeyTab} + case "shift+tab": + return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} case "down": return tea.KeyPressMsg{Code: tea.KeyDown} case "up": diff --git a/cli/internal/tui/requests.go b/cli/internal/tui/requests.go index ec0f5bcde..26abbb53a 100644 --- a/cli/internal/tui/requests.go +++ b/cli/internal/tui/requests.go @@ -14,8 +14,8 @@ import ( "github.com/Flow-Research/workstream/cli/internal/download" ) -// ReadClient is the bounded public read surface, satisfied directly by api.Client. -type ReadClient interface { +// Client is the bounded contributor surface, satisfied directly by api.Client. +type Client 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) @@ -27,9 +27,11 @@ type ReadClient interface { Submission(context.Context, string) (api.Result[api.SubmissionHistory], error) SubmissionFindings(context.Context, string, string, int, *string) (api.Result[api.SubmissionFindingsResult], error) DownloadGuideDocument(context.Context, string, api.TaskGuideDocument, io.Writer) error + ClaimTask(context.Context, string, string, *string) (api.Result[api.ClaimedTask], error) + StartTask(context.Context, string, string, *string) (api.Result[api.MutationTask], error) } -var _ ReadClient = (*api.Client)(nil) +var _ Client = (*api.Client)(nil) type readKind int @@ -376,8 +378,18 @@ func (m *Model) failed(err error, actorRead bool) { m.invalidate() m.clearPage() m.projectName = "" + if m.write.mode == writeConfirm || m.write.mode == writeKeyEntry { + if m.write.outcome == outcomeNone { + m.write = writeState{} + } else { + m.write.mode, m.write.acknowledged, m.write.replay = writeIdle, false, false + } + } var failure *api.Failure if errors.As(err, &failure) { + if failure.Status == 401 || failure.Status == 403 || failure.Status == 404 { + m.write.replayDisabled = true + } if failure.Status == 401 || (actorRead && failure.Status == 403) { m.blocked = true m.actor = "" diff --git a/cli/internal/tui/view.go b/cli/internal/tui/view.go index 091b56c10..74fbd2123 100644 --- a/cli/internal/tui/view.go +++ b/cli/internal/tui/view.go @@ -43,7 +43,14 @@ func (m *Model) collectionText() string { } parts = append(parts, label) } - return strings.Join(parts, " ") + if m.width < 60 { + return strings.Join(parts, " ") + " 1/2/3" + } + result := strings.Join(parts, " ") + if m.width >= 60 { + result += " 1/2/3 select" + } + return result } func (m *Model) sectionText() string { @@ -92,6 +99,30 @@ func (m *Model) renderFooter() string { } func (m *Model) footerText() string { + if m.write.mode == writeKeyEntry { + return "Enter review Esc cancel Ctrl+C quit" + } + if m.write.mode == writeConfirm { + if m.width < 70 { + return "s saved y send up/down inspect\nEsc cancel q quit" + } + return "s saved y send arrows/PgUp/PgDn inspect Esc cancel q quit" + } + if m.write.mode == writePending { + return "Sending one request... q quit" + } + if m.write.mode == writeOutcome { + if m.blocked { + return "up/down inspect Esc return q quit" + } + if m.canReplay() && m.width < 65 { + return "p replay r read up/down inspect\nEsc return q quit" + } + if m.canReplay() { + return "p replay r read arrows/PgUp/PgDn inspect Esc return q quit" + } + return "r read arrows/PgUp/PgDn inspect Esc return q quit" + } if m.guideMode == guideDestination { return "Enter review Esc cancel Ctrl+C quit" } @@ -125,6 +156,15 @@ func (m *Model) footerText() string { if m.loading { return "Loading... r refresh ? help q quit" } + if m.canOpenAction() { + if m.width < 60 { + return "a actions 1/2/3 collections\nEsc back r refresh q quit" + } + if m.width < 90 { + return "a actions 1/2/3 collections Shift+Tab back\nr refresh Esc back q quit" + } + return "a actions 1 Ready 2 Mine 3 Submissions Shift+Tab back r refresh Esc back q quit" + } if !m.detail && len(m.visible()) == 0 { navigation := "/ filter r refresh" if !m.picker { @@ -263,7 +303,7 @@ func (m *Model) View() tea.View { } if m.tooSmall() { guidance := "Resize to at least 40x10. q quit" - if m.filtering || m.guideMode == guideDestination { + if m.filtering || m.guideMode == guideDestination || m.write.mode == writeKeyEntry { guidance = "Resize to at least 40x10. Ctrl+C quit" } v := tea.NewView(lipgloss.Wrap(guidance, max(1, m.width), "")) @@ -300,7 +340,7 @@ func (m *Model) View() tea.View { } func (m *Model) showPanes() bool { - return m.widePanes() && m.guideMode == guideBrowse && !m.help && !m.handoff && m.failure == "" && !m.loading && m.started + return m.widePanes() && m.guideMode == guideBrowse && m.write.mode == writeIdle && !m.help && !m.handoff && m.failure == "" && !m.loading && m.started } func (m *Model) dividerText() string { @@ -329,14 +369,17 @@ func (m *Model) reflow() { } func (m *Model) body() string { + if m.write.mode != writeIdle { + return m.writeText() + } if m.help { - return "KEYBOARD HELP\n\nNavigate\nArrows select a row/document or scroll detail. Tab changes collections or visible detail sections. Esc returns.\n\nInspect\nEnter reads one exact selected resource. r explicitly refreshes; g opens a fresh project picker. / filters only the loaded page.\n\nPages\nn requests exactly one next list or findings page. No polling or project-wide count is implied.\n\nGuide downloads\nd enters a destination for the selected guide original; Enter reviews and y confirms. Esc cancels without downloading.\n\nExit\nc shows inert CLI arguments. q or Ctrl+C quits. ? or Esc closes help.\n\nChecker evidence is not acceptance. A read is not a reservation or authority." + return "KEYBOARD HELP\n\nNavigate\nArrows select a row/document or scroll detail. 1 Ready, 2 Mine, 3 Submissions select collections. Tab advances and Shift+Tab reverses collections or visible detail sections. Esc returns.\n\nInspect\nEnter reads one exact selected resource. r explicitly refreshes; g opens a fresh project picker. / filters only the loaded page.\n\nActions\na on an inspected Ready task or claimed Mine Overview opens Claim or Start. Enter reviews your externally saved UUID; s acknowledges the save warning and y sends one POST. p explicitly replays the exact prior packet when offered. No automatic retry or chained start.\n\nPages\nn requests exactly one next list or findings page. No polling or project-wide count is implied.\n\nGuide downloads\nd enters a destination for the selected guide original; Enter reviews and y confirms. Esc cancels without downloading.\n\nExit\nc shows inert CLI arguments. q or Ctrl+C quits. ? or Esc closes help.\n\nChecker evidence is not acceptance. A read is not a reservation or authority." } if m.handoff { return m.handoffText() } if m.failure != "" { - return m.styles().error.Render(m.failure) + "\nNo retained page or detail is available.\n" + m.failureHelp() + return m.writeBanner() + m.styles().error.Render(m.failure) + "\nNo retained page or detail is available. A failed read does not change a prior write outcome.\n" + m.failureHelp() } if m.loading { return m.loadingText() @@ -345,12 +388,12 @@ func (m *Model) body() string { return "Waiting for terminal size..." } if m.detail { - return m.detailText() + return m.writeBanner() + m.detailText() } if m.widePanes() { - return m.previewText() + return m.writeBanner() + m.previewText() } - return m.listText(m.width) + return m.writeBanner() + m.listText(m.width) } func (m *Model) loadingText() string { diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py index 96af3b5c1..2ff301776 100644 --- a/cli/tests/integration/test_tui_http.py +++ b/cli/tests/integration/test_tui_http.py @@ -13,6 +13,11 @@ from test_contributor_reads_http import ASSIGNMENT_ROW, PROJECT_ROW, SUBMISSION_ROW from test_contributor_task_http import CONTRIBUTOR, READY +from test_contributor_task_mutations import ( + KEY as CLAIM_KEY, + body as mutation_body, + canonical_error, +) from test_http_boundary import ACTOR, PROFILE, PROJECT, TOKEN from test_submission_http import CHECKER as SUBMISSION_CHECKER from test_submission_http import HISTORY, RUN, SUBMISSION @@ -22,6 +27,7 @@ OTHER_PROJECT = "018f0ebc-7966-7e8d-bc4d-1cae1e000099" OTHER_TASK = "018f0ebc-7966-7e8d-bc4d-1cae1e000098" +START_KEY = "018f0ebc-7966-7e8d-bc4d-1cae1e000097" CURSOR = "opaque+/=&?#next" CHECKER = deepcopy(SUBMISSION_CHECKER) CHECKER["task_id"] = TASK @@ -114,6 +120,49 @@ def __call__(self, method, target, authorization): return 200, routes[path] +class Writes(Reads): + """Opt-in writes for action tests; the ordinary Reads peer remains GET-only.""" + + def __init__(self, action="claim"): + super().__init__() + self.action = action + self.replies = [(200, mutation_body(action))] + if action == "start": + self.task["status"] = "claimed" + self.mine = page( + ASSIGNMENT_ROW + | {"title": "Assigned terminal task", "task_status": "claimed"} + ) + + def __call__(self, method, target, authorization): + if method == "GET": + return super().__call__(method, target, authorization) + assert method == "POST" + assert authorization == "Bearer " + TOKEN + assert target == TASK_PATH + "/" + self.action + assert self.replies, "unexpected duplicate TUI POST" + return self.replies.pop(0) + + +def action_detail(terminal, action): + open_ready(terminal) + if action == "start": + terminal.send("2") + terminal.wait("Assigned terminal task") + terminal.send("\r") + terminal.wait(SECRET) + + +def action_confirm(terminal, action, key): + terminal.send("a") + terminal.wait("Caller-owned replay key") + terminal.send(key) + terminal.send("\r") + terminal.wait("Confirm " + action) + assert TASK in terminal.screen and PROJECT in terminal.screen + assert ACTOR in terminal.screen and key in terminal.screen + + def open_ready(terminal, project_name="Terminal project"): terminal.wait(project_name) terminal.send("\r") @@ -283,6 +332,244 @@ def test_hidden_native_confirmation_does_not_download(tmp_path): terminal.quit() +@pytest.mark.parametrize("action", ["claim", "start"]) +def test_task_action_requires_visible_acknowledged_confirmation_and_exact_wire(action): + peer = Writes(action) + key = CLAIM_KEY if action == "claim" else START_KEY + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda path, headers, body: posts.append( + (path, headers, body) + ), + ) as (origin, requests), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + action_detail(terminal, action) + terminal.send("a") + terminal.wait("Caller-owned replay key") + terminal.send(key) + terminal.send("\r") + terminal.wait("Confirm " + action) + assert TASK in terminal.screen and PROJECT in terminal.screen + assert ACTOR in terminal.screen and key in terminal.screen + terminal.send("y") + terminal.pump(0.2) + assert posts == [], "Enter or unacknowledged y dispatched a write" + terminal.resize(30, 8) + terminal.wait("Resize") + terminal.send("sy") + terminal.pump(0.2) + assert posts == [], "hidden confirmation dispatched a write" + terminal.resize(120, 40) + terminal.wait("Confirm " + action) + terminal.send("s") + terminal.send("y") + terminal.wait(action.capitalize() + " confirmed") + assert len(posts) == 1 + assert posts[0][0] == TASK_PATH + "/" + action + assert posts[0][1]["Authorization"] == "Bearer " + TOKEN + assert posts[0][1]["Idempotency-Key"] == key + assert posts[0][1]["Content-Type"] == "application/json" + assert posts[0][2] == b"{}" + terminal.send("y") + terminal.pump(0.2) + assert len(posts) == 1, "repeated confirmation key dispatched twice" + assert [method for method, _, _ in requests].count("POST") == 1 + terminal.quit() + + +def test_pasted_replay_key_requires_manual_confirmation_and_pasted_commands_are_inert(): + peer = Writes() + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda path, headers, body: posts.append( + (path, headers, body) + ), + ) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + action_detail(terminal, "claim") + terminal.send("a") + terminal.wait("Caller-owned replay key") + terminal.send("\x1b[200~" + CLAIM_KEY + "\ny\x1b[201~") + terminal.wait("Paste one UUID without spaces or control characters") + assert posts == [] + terminal.send("\r") + terminal.wait("Enter a caller-owned UUID") + assert "Confirm claim" not in terminal.screen + terminal.send("\x1b") + terminal.wait(SECRET) + terminal.send("a") + terminal.wait("Caller-owned replay key") + terminal.send("\x1b[200~" + CLAIM_KEY + "\x1b[201~") + terminal.wait(CLAIM_KEY) + assert posts == [] + terminal.send("\r") + terminal.wait("Confirm claim") + terminal.send("\x1b[200~sy\x1b[201~") + terminal.pump(0.2) + assert posts == [], "pasted confirmation keys dispatched a write" + terminal.send("y") + terminal.pump(0.2) + assert posts == [], "pasted acknowledgment was accepted" + terminal.send("s") + terminal.send("y") + terminal.wait("Claim confirmed") + assert len(posts) == 1 and posts[0][2] == b"{}" + terminal.quit() + + +@pytest.mark.parametrize( + ("action", "corruption"), + [ + ("claim", "task_project"), + ("claim", "assignment_actor"), + ("claim", "malformed"), + ("start", "task_project"), + ], +) +def test_task_action_corrupt_success_is_unknown_and_not_retried(action, corruption): + peer = Writes(action) + value = mutation_body(action) + if corruption == "task_project": + (value["task"] if action == "claim" else value)["project_id"] = OTHER_PROJECT + if action == "claim": + value["assignment"]["project_id"] = OTHER_PROJECT + elif corruption == "assignment_actor": + value["assignment"]["contributor_id"] = OTHER_TASK + value["assignment"]["assigned_by"] = OTHER_TASK + else: + value = b"{" + peer.replies = [(200, value)] + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda path, headers, body: posts.append( + (path, headers, body) + ), + ) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + action_detail(terminal, action) + action_confirm(terminal, action, CLAIM_KEY) + terminal.send("s") + terminal.send("y") + terminal.wait("Outcome unknown") + terminal.pump(0.2) + assert len(posts) == 1, "malformed success was automatically retried" + assert "confirmed" not in terminal.screen.lower() + assert TOKEN.encode() not in terminal.raw + terminal.quit() + + +def test_pending_claim_consumes_navigation_refresh_replay_and_resize_keys(): + peer = Writes() + posted, release = Event(), Event() + posts = [] + + def capture(path, headers, body): + posts.append((path, headers, body)) + posted.set() + assert release.wait(10), "test did not release pending POST" + + with ( + socket_peer(peer, capture_post=capture) as (origin, requests), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + try: + action_detail(terminal, "claim") + action_confirm(terminal, "claim", CLAIM_KEY) + terminal.send("s") + terminal.send("y") + assert posted.wait(5) + terminal.wait("Sending claim") + terminal.send("r123apy") + terminal.resize(30, 8) + terminal.wait("Resize") + terminal.send("r123apy") + terminal.resize(120, 40) + terminal.wait("Sending claim") + assert len(posts) == 1 + release.set() + terminal.wait("Claim confirmed") + assert len(posts) == 1 + assert [method for method, _, _ in requests].count("POST") == 1 + assert TASK in terminal.screen and CLAIM_KEY in terminal.screen + terminal.quit() + finally: + release.set() + + +def test_unknown_replay_denial_preserves_original_unknown_packet(): + peer = Writes() + peer.replies = [ + (200, b"{"), + (403, canonical_error()), + ] + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda path, headers, body: posts.append( + (path, headers, body) + ), + ) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + action_detail(terminal, "claim") + action_confirm(terminal, "claim", CLAIM_KEY) + terminal.send("s") + terminal.send("y") + terminal.wait("Outcome unknown") + assert len(posts) == 1 + terminal.send("p") + terminal.wait("Confirm claim") + assert CLAIM_KEY in terminal.screen + terminal.send("s") + terminal.send("y") + terminal.wait("Request denied") + assert len(posts) == 2 + assert [ + (path, headers["Idempotency-Key"], body) for path, headers, body in posts + ] == [ + (TASK_PATH + "/claim", CLAIM_KEY, b"{}"), + (TASK_PATH + "/claim", CLAIM_KEY, b"{}"), + ] + assert "Outcome unknown" in terminal.screen + terminal.quit() + + +def test_confirmed_claim_survives_separate_read_failure_without_restoring_text(): + peer = Writes() + posts = [] + with ( + socket_peer( + peer, + capture_post=lambda path, headers, body: posts.append( + (path, headers, body) + ), + ) as (origin, _), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + action_detail(terminal, "claim") + action_confirm(terminal, "claim", CLAIM_KEY) + terminal.send("s") + terminal.send("y") + terminal.wait("Claim confirmed") + peer.overrides[TASK_PATH] = lambda _: denial(404) + terminal.send("r") + terminal.wait("Denied") + assert "Claim confirmed" in terminal.screen + assert SECRET not in terminal.screen + assert len(posts) == 1 + terminal.quit() + + def test_guide_download_requires_confirmation_and_verifies_bytes(tmp_path): reads = guide_reads() with ( diff --git a/cli/tests/integration/test_tui_public_api.py b/cli/tests/integration/test_tui_public_api.py index 9dcffb06e..4101d2ccb 100644 --- a/cli/tests/integration/test_tui_public_api.py +++ b/cli/tests/integration/test_tui_public_api.py @@ -1,9 +1,10 @@ """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. +immutable attempt. The original test remains GET-only; the separate action +test proves claim/start through the native UI. Neither is a public intake or +external artifact-provider journey. No AUTH/TASK port is replaced; only the +signed fixture issuer's JWKS transport is local. """ import asyncio @@ -219,3 +220,151 @@ async def wait(text): ) assert ("GET", paths["submission"], b"") in requests assert await counts() == before, "read-only TUI mutated product records" + + +@pytest.mark.asyncio +async def test_tui_real_signed_claim_replay_start_replay_then_fresh_revocation( + tmp_path, auth_database_env, admin_access +): + signed = admin_access.signed + async with _prepared_packet(auth_database_env, tmp_path) as h: + 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) + access = SimpleNamespace(signed=signed) + ready_id = await create_task(access, h.project_id, owner) + project, task = str(h.project_id), str(ready_id) + detail_path = f"/api/v1/tasks/{task}" + ready = await client.get(detail_path, headers=owner.headers) + assert ready.status_code == 200, ready.text + assert ready.json()["status"] == "ready" + projects = await client.get( + "/api/v1/actors/me/projects?limit=50", headers=owner.headers + ) + assert projects.status_code == 200, projects.text + project_name = projects.json()["items"][0]["name"] + manager = await project_manager(admin_access, h.project_id) + claim_key, start_key = str(uuid4()), str(uuid4()) + writes = [] + 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" and scope["method"] == "POST": + writes.append( + ( + scope["path"], + dict(scope["headers"]).get(b"idempotency-key", b"").decode(), + ) + ) + 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) + + async def confirm(action, key): + terminal.send("a") + await wait("Caller-owned replay key") + terminal.send(key) + terminal.send("\r") + await wait("Confirm " + action) + assert task in terminal.screen and key in terminal.screen + assert ( + project in terminal.screen and str(owner.id) in terminal.screen + ) + terminal.send("s") + terminal.send("y") + await wait(action.capitalize() + " confirmed") + + async def replay(action): + terminal.send("p") + await wait("Confirm " + action) + terminal.send("s") + terminal.send("y") + await wait(action.capitalize() + " confirmed") + + await wait(project_name) + terminal.send("\r") + await wait("Evaluate proof") + terminal.send("\r") + await wait(task) + await confirm("claim", claim_key) + await replay("claim") + # Claim does not implicitly start. A fresh Mine read and exact + # task detail are required before opening the second action. + terminal.send("\x1b") + await wait(ready.json()["description"]) + terminal.send("2") + await wait("Evaluate proof") + terminal.send("/Evaluate proof\r") + await wait("local filter: Evaluate proof") + terminal.send("\r") + await wait(task) + await confirm("start", start_key) + await replay("start") + current = await client.get(detail_path, headers=owner.headers) + assert current.status_code == 200, current.text + assert current.json()["status"] == "in_progress" + async with h.factory() as session: + assignments = ( + await session.scalars( + select(TaskAssignment).where(TaskAssignment.task_id == task) + ) + ).all() + assert len(assignments) == 1 + assert assignments[0].contributor_id == str(owner.id) + 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 fresh authority after native TUI replay"}, + ) + assert revoked.status_code == 200, revoked.text + terminal.send("p") + await wait("Confirm start") + terminal.send("s") + terminal.send("y") + await wait("Request denied") + assert "Start confirmed" in terminal.screen + assert ready.json()["description"] not in terminal.screen + await asyncio.to_thread(terminal.quit) + assert owner.token.encode() not in terminal.raw + + assert writes == [ + (detail_path + "/claim", claim_key), + (detail_path + "/claim", claim_key), + (detail_path + "/start", start_key), + (detail_path + "/start", start_key), + (detail_path + "/start", start_key), + ] + for denied_key in (start_key, str(uuid4())): + denied = await client.post( + detail_path + "/start", + headers=owner.headers | {"Idempotency-Key": denied_key}, + json={}, + ) + assert denied.status_code == 403, denied.text + assert (await client.get(detail_path, headers=owner.headers)).status_code == 404 diff --git a/cli/tests/integration/tui_terminal_support.py b/cli/tests/integration/tui_terminal_support.py index 91980b276..5bcdb4e9d 100644 --- a/cli/tests/integration/tui_terminal_support.py +++ b/cli/tests/integration/tui_terminal_support.py @@ -870,8 +870,12 @@ def snapshot(): @contextmanager -def socket_peer(respond): - """Threaded hostile HTTP peer: every unexpected request is recorded/fails.""" +def socket_peer(respond, *, capture_post=None): + """Threaded hostile HTTP peer: every unexpected request is recorded/fails. + + The optional hook observes the exact POST headers and bytes before the + ordinary three-argument responder runs. Existing GET-only peers stay strict. + """ requests = [] failures = [] @@ -880,6 +884,9 @@ def do_GET(self): # noqa: N802 - HTTP handler interface request = (self.command, self.path, self.headers.get("Authorization")) requests.append(request) try: + if self.command == "POST" and capture_post is not None: + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + capture_post(self.path, dict(self.headers), body) result = respond(*request) status, body = result[:2] media_type = result[2] if len(result) == 3 else "application/json" diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index 698c71f2c..a2a611fc4 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -1,7 +1,8 @@ # Workstream contributor terminal workbench This is the presentation contract for the opt-in `workstream tui` contributor -workbench after [WS-CLI-001-50](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md). +workbench after [WS-CLI-001-51](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md), +extending the presentation established by [WS-CLI-001-50](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md). It describes the intended merged interface, not a new API, deployed service, published binary, or completed product journey. The existing read and download boundaries are recorded in [WS-CLI-001-48](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) @@ -20,16 +21,47 @@ offers `Overview` and paged `Findings`. Contribution Evidence is an inert CLI handoff, not a fourth fetched collection. The project picker uses `me projects`; the separate system-reader `project list` does not feed this workbench. -The workbench makes no product mutation request: no claim, start, preparation, -Submission, Review, policy, management, payment, or recovery action. Its only +The workbench exposes deliberate exact-task Claim and Start. It exposes no +preparation, Submission, Review, policy, management or payment mutation. Its only local file write is an explicitly confirmed, verified single-guide-document download. It does not poll, automatically retry, cache stale authority, store credentials or history, acquire tokens, launch a shell or viewer, or copy a command to the clipboard. It uses the existing typed public client and the supplied `WORKSTREAM_API_URL` and `WORKSTREAM_TOKEN`; there is no Workstream login. A profile read may have the backend's existing actor admission, -last-seen and audit effects, so “read-only” describes product operations, not -an unaudited server interaction. +last-seen and audit effects; reads are not unaudited server interactions. + +## Claim, start and uncertain outcomes + +`a actions` is available on an exact freshly inspected Ready task with `ready` +status, or Mine Overview with `claimed` task status. Assignment status on a Mine +row is not task status and cannot select Start. Each action requires a caller- +supplied UUID and confirmation of the exact actor/project/task/action/key and +available locked terms. Explain and acknowledge saving the action, task and key +outside this session before dispatch; the application cannot verify that external +save. It generates no fallback key and stores no operation history. Reason is +omitted consistently through the existing typed client. + +Enter advances from key entry to confirmation; `s` acknowledges the external-save +warning. Confirmation is not dispatch until explicit `y` at a usable size. `p` +opens explicit same-packet replay, with the same acknowledgment and confirmation. +Text entry, +confirmation and pending-write states consume their own keys; navigation, +refresh and repeated keys cannot duplicate or replace a pending POST. Claim +never automatically starts work. Backend authorization remains fresh on every +call, including exact replay. Validate selected-project and claim actor binding +in addition to the typed client's task/receipt checks. + +Unknown outcome retains the exact packet and requires explicit unchanged replay. +Transport loss, cancellation or a malformed success is not rejection or rollback. +A canonical denied replay does not erase earlier uncertainty. A confirmed receipt +is independent of a subsequent read failure; no failed refresh undoes a confirmed +write. Clear protected descriptions and terms on denial and disable in-session +replay after session failure. Retained recovery references are minimal and inert. +Quit cancels the request, not server effects. On restart the caller must use their +saved action/task/key with the explicit CLI, same API origin and actor; there is +no automatic crash-recovery claim. Claim replay after advancing to start may +validly conflict because backend replay is state-dependent. Every displayed fact belongs to its returned scope. List rows are observations, not reservations, current eligibility, or authority for a later command. @@ -146,8 +178,9 @@ stored Submission outcome. `n` fetches only the returned findings continuation; `?` groups help by Navigate (arrows, `Tab`, `Esc`), Inspect (`Enter`, `r`, `g`, `/`), Pages (`n`), Guide downloads (`d`, destination `Enter`, confirmation `y`/`Esc`), and Exit (`c`, `q`, Ctrl+C). There are no independent pane-focus -keys. Show only applicable -keys in the contextual footer. `q` is ordinary text inside filter or directory +keys. `1`, `2`, `3` directly select Ready, Mine and Submissions from ordinary +views; Shift+Tab reverses collection/section navigation. Show only applicable +keys in the contextual footer. `q` is ordinary text inside filter, replay-key or directory entry; Ctrl+C always exits. Help and inert handoff may scroll without changing selection or sending a request. `c` shows the equivalent existing CLI command as separate, inert argv arguments, never an executable shell string, and never @@ -176,21 +209,22 @@ percentage. ## Future boundary, not current behavior The broader [WS-CLI-001 initiative](../../.commitrail/initiatives/WS-CLI-001/OVERVIEW.md) -retains separate in-TUI Contribution Evidence, product-write and management +retains separate in-TUI Contribution Evidence, preparation/submission and management boundaries. Evidence first needs a shared typed single-page contribution port; the complete explicit CLI export is not a TUI page source. Human Review and fulfillment are separate product gaps, not empty workbench screens. A later system-reader picker may consume the distinct authorized project catalogue; it must not turn exact-grant contributor membership into system discovery. -Before any later TUI write ships, its specific public operation needs an -authorized, deliberate confirmation and durable private receipt of exact -unchanged inputs before one request, including actor/API origin and any +Before generated-key or automatic restart recovery ships, its specific public +operation needs durable private custody of exact unchanged inputs before one +request, including actor/API origin and any caller-owned key, admission, lineage acknowledgement or source-file digest. An unknown response requires operation-specific recovery, never automatic retry or inference from an empty history page. Claim/start keys, preparation's ZIP/packet/key, Submission's admission/packet, guide correction receipts, and Operator retry/reconciliation selectors are not interchangeable. Design and prove each flow at its own boundary; do not add a generic write journal or -silent handoff to the current read workbench. The historical design decisions +silent handoff. Claim/start currently use externally saved caller-owned keys, +matching the explicit CLI, not automatic custody. The historical design decisions and separate operation records remain in the [initiative overview](../../.commitrail/initiatives/WS-CLI-001/OVERVIEW.md). diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index c8bbf6ca2..15864b13b 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -64,8 +64,11 @@ WS-CLI-001-49 adds findings pages and confirmed verified guide downloads. [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) redraws that same local capability with visible navigation, readable instructions and truthful loaded-page/evidence states, without new API or product operations. -Contribution Evidence remains an explicit CLI handoff; TUI product mutations -and management are separate. Neither this interface nor release automation +[WS-CLI-001-51](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md) adds direct +collection navigation and separately confirmed Claim/Start through the existing +public API, with caller-owned saved keys and explicit replay. Contribution +Evidence remains an explicit CLI handoff; ZIP preparation/Submission, other +TUI mutations and management remain separate. Neither this interface nor release automation establishes hosted operation or a published binary. ## Product Goal @@ -453,7 +456,7 @@ 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 +`workstream tui` is a separate opt-in contributor workbench for 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. @@ -461,8 +464,12 @@ Denial removes affected content; late responses cannot replace the current view. WS-CLI-001-49 adds Guide selection and confirmed single-document downloads through shared private staging and no-overwrite publication, plus exact-Submission paged Findings. Evidence remains an inert CLI handoff, not automatic export. -Product writes, polling, management forms and operation-specific recovery -remain outside this bounded workbench. Explicit text/JSON commands stay usable +WS-CLI-001-51 adds direct collection navigation and separate, explicitly +confirmed claim/start through the existing typed public writes. Caller-owned +externally saved UUID keys support explicit unchanged replay; unknown outcomes +never trigger retry or imply rollback. Automatic crash recovery, ZIP preparation, +Submission writes, polling and management forms 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 @@ -596,7 +603,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 contributor read workbench** | Existing explicit commands retain text/JSON/noninteractive behavior. [WS-CLI-001-48](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) supplies exact-grant project, Ready/active Mine/own Submission browsing and fresh details/context/intake with explicit pagination and denial handling. [WS-CLI-001-49](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) supplies locked-guide metadata, confirmed verified local downloads and paged checker findings. [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) replaces only their presentation with visible collections/sections, readable instructions and accurate page/evidence labels | Contribution Evidence remains a manual CLI handoff; a shared typed single-page contribution port precedes in-TUI Evidence. TUI product writes/recovery and management remain separate. Protected first binary publication and hosted journey proof remain outstanding | +| CLI and terminal interface | **Public CLI clients and contributor workbench with deliberate claim/start** | Explicit commands retain text/JSON/noninteractive behavior. [WS-CLI-001-48](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) supplies scoped contributor reads; [WS-CLI-001-49](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) adds verified guide downloads/findings; [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) improves presentation. [WS-CLI-001-51](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md) adds direct navigation and exact confirmed claim/start with caller-owned saved keys, explicit replay and honest unknown outcomes | ZIP preparation/Submission, automatic crash recovery and management remain separate TUI boundaries. Contribution Evidence remains an inert CLI handoff pending a typed single-page port. Protected publication and hosted journey proof remain separate operational claims | | 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 project-owned POST implementation consumes generic model/trial capabilities and exact approved predecessor inputs; its qualification boundaries are recorded with the checker implementation. The independent offline gVisor feasibility spike remains historical mechanism evidence | Obtain a fresh PRE/POST-only correction of the retained proposal, reconcile the implemented PRE/POST images and qualify the exact runtime/material path; 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 | @@ -942,6 +949,10 @@ cannot be reused as post-submission review-gate evidence. See the [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) replaces the presentation of those same reads/downloads so contributors can find instructions and distinguish observations from stored outcomes. + [WS-CLI-001-51](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-51.md) + adds exact claim/start and direct collection navigation. Saved caller-owned + keys, explicit confirmation and same-packet replay preserve existing backend + authority; no automatic write or claim-to-start chain is introduced. Contribution Evidence remains a manual CLI handoff; broader TUI work remains separate. Further public commands require their own usable API contracts; binary-release automation is prepared, while protected first publication @@ -1381,7 +1392,9 @@ Delivered foundations (bounded local journey proven; no deployment claim) own Submission -> paged checker findings; Contribution Evidence -> manual CLI handoff WS-CLI-001-50 -> visible contributor navigation + readable instructions/evidence same typed reads/download; no new API, management or mutation capability - later Evidence needs typed single-page port; writes need per-operation recovery + WS-CLI-001-51 existing public claim/start -> explicit TUI confirmation + caller-owned key + direct Ready/Mine/Submissions navigation; unknown -> explicit unchanged replay + later ZIP/Submission need per-operation recovery; Evidence needs typed single-page port 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 @@ -1512,10 +1525,11 @@ and award history never changes. ## Remaining Release Gates -Client scope remains distinct from product completion: the local read-only TUI -now presents the existing contributor reads and verified guide downloads more -clearly; it does not complete human review, payment or hosted proof. In-TUI -Contribution Evidence and mutations are separate optional interface boundaries, +Client scope remains distinct from product completion: the contributor TUI +presents existing reads/downloads plus separate deliberate claim/start with +externally saved replay keys. It does not complete human review, payment or +hosted proof. In-TUI Contribution Evidence, ZIP preparation/Submission and +automatic crash recovery remain separate interface boundaries, not new backend lifecycle prerequisites. CLI binary distribution still requires protected publication and independent public-asset/install verification.