From 9c255431f23a4fa48a3615b387610b3367f476af Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 20 Aug 2026 01:15:48 -0700 Subject: [PATCH 01/15] updating workbench --- build.gradle | 2 +- docs/agent/feature-map.md | 4 +- docs/agent/repository-index.md | 2 + docs/pickleball-workbench.md | 141 +++- maven-consumer-project/pom.xml | 2 +- pickleball-workbench/AGENTS.md | 35 +- .../workbench/player/LiveScenarioPlayer.java | 255 ++++++ .../dscode/workbench/ui/WorkbenchFrame.java | 744 +++++++++++++----- .../player/LiveScenarioPlayerTest.java | 161 ++++ .../guidance/docs/agent/feature-map.md | 4 +- .../guidance/docs/agent/repository-index.md | 2 + .../guidance/docs/pickleball-workbench.md | 141 +++- 12 files changed, 1226 insertions(+), 267 deletions(-) create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java diff --git a/build.gradle b/build.gradle index 8ba65c6e..9d95a9bb 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ plugins { } group = 'tools.dscode' -version = '2.1.8' +version = '2.1.9' repositories { mavenCentral() diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index a9016d52..87122098 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -5,7 +5,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Capability | Implementation/search anchors | Consumer/internal coverage | Canonical documentation | |---|---|---|---| | Build, publication, Java compatibility | `build.gradle`; `settings.gradle`; `src/main/aspectj`; `gradle/pickleball-published-variant.gradle`; search `publishing`, `shadowJar`, `pickleballPublishedElements`, `aspectj`, `JavaLanguageVersion` | root tests; `:pickleball-workbench:test`; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / thin Swing UI | `pickleball-workbench`; `gradle/pickleball-published-variant.gradle`; `WorkbenchApplication`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.ui`; Pickleball `WorkbenchWorkerMain`; `DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY` | `:pickleball-workbench:test`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `ControlBridgeClientTest`; `DynamicSuiteBootstrapWorkbenchRootTest`; direct `sync` / `worker-check` / `live-check`; packaged UI smoke | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `gradle/pickleball-published-variant.gradle`; `WorkbenchApplication`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; Pickleball `WorkbenchWorkerMain`; `DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY` | `:pickleball-workbench:test`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; `DynamicSuiteBootstrapWorkbenchRootTest`; direct `sync` / `worker-check` / `live-check`; packaged UI smoke | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core bootstrap `ControlRuntime`; Workbench `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | @@ -43,4 +43,4 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and thin Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI is intentionally execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the session buffer, stable line identities, selection, playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index becaa037..118cfbec 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -443,6 +443,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` @@ -457,6 +458,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerStatus.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index b0e888bc..b6237d54 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -2,7 +2,7 @@ Pickleball Workbench is the separate executable companion for interactive Pickleball execution and investigation. It depends on the normal shaded/woven `tools.dscode:pickleball` artifact; normal Pickleball consumers do not depend on Workbench. -Workbench replaces the former Pickleball Studio application. The final surface is deliberately execution-oriented: project synchronization, a persistent consumer worker, live runtime control, Mapping, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and a thin Swing UI. +Workbench replaces the former Pickleball Studio application. The supported architecture is deliberately execution-oriented: project synchronization, a persistent consumer worker, live runtime control, Mapping, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and a Swing UI over the same service seam. ## Architecture @@ -14,7 +14,7 @@ pickleball-workbench -> pickleball Pickleball owns scenario execution semantics, Cucumber integration, DynamicControl/Gherkin execution, Mapping, browser/service behavior, the consumer-side Control Bridge, semantic hooks/breakpoints, Step Overrides, and woven Cucumber/AspectJ behavior. -Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the controller-side bridge client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, and the thin Swing UI. +Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the controller-side bridge client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, the headless live-scenario presentation model, and the Swing adapter. MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. @@ -43,10 +43,10 @@ The executable is: pickleball-workbench/build/libs/pickleball-workbench-.jar ``` -Synchronize a consumer project before starting a worker: +Synchronize a consumer project before starting a worker manually: ```powershell -$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-2.1.8.jar" +$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" java -jar $workbenchJar sync ".\maven-consumer-project" ``` @@ -54,20 +54,107 @@ Synchronization uses the selected project wrapper to establish compiled output a ## Swing UI -Start the thin Workbench UI for one consumer project: +Start Workbench for one consumer project: ```powershell java -jar $workbenchJar ui ".\maven-consumer-project" ``` -The Swing UI is a presentation adapter over the same `WorkbenchServices` / `WorkbenchController` seam used by MCP. It does not own a second worker manager, bridge client, Mapping implementation, or Pickleball execution model. +The Swing UI is a presentation adapter over the same `WorkbenchServices` / `WorkbenchController` seam used by MCP. It does not own a second worker manager, bridge client, Mapping implementation, Gherkin execution engine, or Pickleball runtime model. -The UI provides: +### Player-style layout + +The primary workspace is now arranged as an interactive scenario player: + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ Project / readiness ⏮ ◀ ▶ ⏸ ■ Player status │ +├───────────────────────────────┬─────────────────────────────────────┤ +│ LIVE SCENARIO EDITOR │ Mapping | Terminal | Diagnostic Log │ +│ │ │ +│ Feature: ... │ selected right-side workspace │ +│ Scenario: ... │ │ +│ ▶ next playhead step │ │ +│ selected/other line │ │ +├───────────────────────────────┤ │ +│ Step Editor / Command ▶ │ │ +│ [ live command text ] │ │ +└───────────────────────────────┴─────────────────────────────────────┘ +│ Workbench/session activity │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +The left side contains only the Live Scenario Editor and compact Step Editor / Command. The right side has one tabbed workspace for Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls are available from the **Session** menu. Existing investigation tools are available from **Tools > Advanced Controls** so the underlying capabilities are preserved without dominating the normal workflow. + +### Live scenario buffer and player state + +`LiveScenarioPlayer` is a headless Workbench-side presentation model. It owns only: + +- stable line identities independent of display line number; +- the live session buffer; +- selected line; +- playhead insertion point; +- pending/executed/failed presentation status; +- player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. + +Selection and playhead are independent. Selecting another line does not move the playhead. New Step Editor commands are inserted at the playhead insertion point, not at the text caret or selected line. + +The initial buffer is an interactive session buffer; it is not automatically written back to consumer `.feature` files. + +Step Editor gestures are intentionally explicit: + +```text +Enter insert a new command at the playhead +Ctrl+Enter update the selected pending executable step +▶ execute the Step Editor text in isolation through the existing live service +``` + +Executed or failed buffer steps cannot be edited in place in this phase because the UI must not imply that browser, service, or other external side effects were undone. + +The **First** and **Step Back** controls are navigation-only in this phase. They do not claim to rewind Pickleball runtime state or undo external side effects. + +### Phase 1 execution boundary + +The current player-style increment establishes the new layout and headless player/buffer state without inventing new Pickleball runtime semantics. + +The main Play/Pause/Stop controls currently update the buffer/player state model only. Automatic buffered execution, wait-at-end execution, and add-and-continue behavior are the next implementation phase. That loop must be wired through an explicit Pickleball-owned Gherkin/runtime contract; Swing must not strip `Given`/`When`/`Then`, create a second step matcher, or otherwise guess how displayed Gherkin maps to detached step execution. + +The small Step Editor Play button continues to use the existing `WorkbenchServices.executeStep` contract unchanged. It pauses the main player state before isolated execution and does not automatically resume it afterward. + +### Mapping tab + +There is no GUI-defined `Current Scope` concept. + +The target Mapping design is a single NodeMap selector populated from the real `ParsingMap` associated with the selected step, with common NodeMaps remaining available across the live scenario. The GUI must not hard-code names or recreate inheritance rules. + +The Phase 1 UI therefore leaves the NodeMap selector unavailable until the required Pickleball-side ParsingMap inspection contract exists. Existing Mapping get/put/resolve controls remain available as compatibility controls. The Swing Mapping put control continues to send entered values as text; MCP continues to support arbitrary JSON-compatible Mapping values through the shared service method. + +Structural NodeMap browsing/mutation is a later phase and must be implemented against real worker-side Pickleball state. + +### Terminal and Diagnostic Log Explorer + +The Terminal tab currently displays Workbench UI activity only. Worker log streaming, level filtering, search, and auto-scroll belong to the Terminal phase and must use the appropriate worker/Workbench logging source without violating the MCP stdout contract. + +The Diagnostic Log Explorer tab is intentionally a placeholder in Phase 1. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: + +1. `run-catalog.json` +2. selected `run-index.json` / `clusters.json` +3. scenario `summary.json` +4. relevant `events.jsonl` +5. existing comparison/fingerprint metadata +6. PNG only when visual content must be inspected +7. raw trace only when structured evidence is insufficient + +The Swing UI must not create a competing diagnostic storage format or fake retained-run data. + +### Existing advanced capabilities + +The redesign preserves the underlying existing capabilities: - selected project display and synchronization/status refresh; - synchronize, start worker, restart fresh worker without rebuilding, and stop worker; - worker PID/runtime/scenario/pause status; -- live raw Gherkin step input with optional argument text and result/status output; +- live raw Gherkin execution; - Mapping get, put, and resolve; - incremental semantic-event display with timestamp, hook, step/phrase, and signature detail; - Step Override list, worker-side compile/replace, remove, and clear; @@ -76,13 +163,11 @@ The UI provides: - semantic breakpoint list, add, remove, and clear with hook/filter/one-shot/finite-lease controls; - clean Workbench shutdown when the window closes. -The Mapping put control stores the entered Swing value as text. MCP continues to support arbitrary JSON-compatible Mapping values through the same service method. - -The Step Override editor sends its source template unchanged to the worker. The source must contain `{{CLASS_NAME}}`; generated class naming, compilation, classloading, rule registration, matching, captures, replacement, and cleanup remain worker-side Pickleball responsibilities. Browser page, screenshot, service-call, event, and breakpoint controls expose the existing bridge contracts rather than reimplementing them in Swing. +The Step Override editor sends its source template unchanged to the worker. The source must contain `{{CLASS_NAME}}`; generated class naming, compilation, classloading, rule registration, matching, captures, replacement, and cleanup remain worker-side Pickleball responsibilities. Browser page, screenshot, service-call, event, and breakpoint controls expose existing bridge contracts rather than reimplementing them in Swing. Synchronization, worker actions, live bridge calls, Mapping operations, event refresh, Step Override actions, browser/screenshot evidence, service calls, and breakpoint actions run off the Swing Event Dispatch Thread. Live controls are enabled only while the Workbench-owned worker is running and paused. -The UI is intentionally not a project IDE, file editor, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. +The UI is intentionally not a project IDE, general feature-file editor, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. ## MCP stdio @@ -196,27 +281,33 @@ io.modelcontextprotocol.sdk:mcp-core:2.0.0 io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0 ``` -## Manual UI acceptance +## Manual UI acceptance for the Phase 1 player foundation ```powershell -$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-2.1.8.jar" +$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" java -jar $workbenchJar ui ".\maven-consumer-project" ``` -Use the UI-owned worker for this smoke test; do not run `worker-check` or `live-check` concurrently with the UI. +Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. -1. **Status:** click **Start Worker** and verify `Paused: true` with a PID/runtime/scenario. -2. **Live Gherkin:** execute `CONTROL API TEST STEP` and verify `Status: SUCCESS`. -3. **Mapping:** put/get/resolve `OVERRIDE / workbenchLiveValue = first` and verify `first` is returned. -4. **Step Overrides:** leave the prefilled id/regex/source, click **Compile / Replace**, and verify `Status: SUCCESS` plus one installed override. In **Live Gherkin**, execute `WORKBENCH UI OVERRIDE alpha`; then in **Mapping**, get `OVERRIDE / workbenchStepOverrideValue` and verify `ui-alpha`. Return to **Step Overrides**, click **Remove ID**, and verify the installed list is empty. -5. **Evidence / Service Call:** execute `%health-full-url` and verify `Status: SUCCESS` and `HTTP status: 200`. -6. **Evidence / Browser:** in **Live Gherkin**, execute `navigate to: URL.home`; then click **Read Page** and verify the URL/title/page source contains the Pickleball test page. Click **Capture Screenshot** and verify a PNG image is displayed. -7. **Breakpoints:** with the prefilled `BEFORE_STEP`, `CONTROL API TEST STEP`, one-shot, and `120` second lease, click **Add** and verify one breakpoint is listed. Copy its generated id into **Breakpoint ID (for remove)**, click **Remove ID**, and verify the list is empty. -8. **Recent Events:** verify semantic events are present and include sequence, timestamp, hook, and step/phrase/signature detail. -9. **Lifecycle:** note the PID, click **Restart Worker**, verify a different PID with `Paused: true`, execute one live step successfully, then click **Stop Worker** and verify `Not running (exit=0)`. +1. Verify the top-level layout has the Live Scenario Editor and compact Step Editor on the left, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. +2. Insert multiple commands with Enter and verify each is inserted at the visible playhead while selection can remain on another line. +3. Select a pending command, change its text, press Ctrl+Enter, and verify its displayed line updates without changing its stable position semantics. +4. Use First and Step Back and verify the playhead indicator moves independently from the selection. Treat these as navigation-only; no runtime rewind is claimed. +5. Use Play/Pause/Stop and verify player presentation states, including `Waiting for next step...` when Play has no next buffered command. Do not treat this as Phase 2 automatic runtime execution. +6. Open **Session**, synchronize/start a worker, select or enter a valid existing live raw Gherkin command, click the small Step Editor Play button, and verify it delegates isolated execution and leaves the main player paused. +7. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Existing get/put/resolve controls remain usable with a paused worker. +8. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +9. Verify blocking runtime actions leave the Swing UI responsive. ## Regression +For the player state model and Swing/controller behavior: + +```powershell +.\gradlew.bat :pickleball-workbench:test +``` + For shared controller/MCP behavior: ```powershell @@ -226,7 +317,7 @@ For shared controller/MCP behavior: For persistent worker/live behavior: ```powershell -$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-2.1.8.jar" +$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" java -jar $workbenchJar sync ".\maven-consumer-project" java -jar $workbenchJar worker-check ".\maven-consumer-project" diff --git a/maven-consumer-project/pom.xml b/maven-consumer-project/pom.xml index 96d945ed..942badc7 100644 --- a/maven-consumer-project/pom.xml +++ b/maven-consumer-project/pom.xml @@ -13,7 +13,7 @@ UTF-8 21 - 2.1.8 + 2.1.9 diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index a4e31c99..6774d0de 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -47,19 +47,46 @@ The thin Swing adapter lives under: tools.dscode.workbench.ui ``` -Launch it with: +The headless live-scenario presentation model lives under: + +```text +tools.dscode.workbench.player +``` + +Launch the UI with: ```text java -jar pickleball-workbench-.jar ui ``` -The UI must remain execution-oriented and use `WorkbenchServices` / `WorkbenchController`. Do not add a project IDE, file editor, generic process manager, generic Maven/Gradle UI, Gradle Tooling API browser, source navigator, or collaboration subsystem. +The UI is player-style and execution-oriented. Its primary layout is: + +```text +left: Live Scenario Editor + compact Step Editor / Command +right: Mapping | Terminal | Diagnostic Log Explorer +``` + +Low-level lifecycle controls live under the Session menu and existing investigation controls remain available under Advanced Controls rather than dominating the permanent workspace. + +`LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, selected line, playhead insertion point, pending/executed/failed visual status, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. + +Selection and playhead are separate. Inserting a Step Editor command occurs at the playhead insertion point and does not depend on the selected line. Enter inserts a new command; Ctrl+Enter updates the selected pending step. Already executed or failed buffer steps are not edited in place because the UI must not imply that browser/service/external side effects were undone. + +The small Step Editor Play button delegates the text unchanged through the existing `WorkbenchUiController.executeStep` / `WorkbenchServices.executeStep` path and leaves the main player paused. Do not strip Gherkin keywords or add a Swing-side step matcher. A future main player execution loop must use an explicit Pickleball-owned Gherkin/runtime contract rather than guessing how display lines map to detached step text. + +### Current player implementation phase + +The first player-style increment intentionally establishes presentation/state only. The main Play/Pause/Stop controls currently drive the headless buffer/player state; automatic buffered runtime execution belongs to the next phase after a safe Pickleball-owned full-Gherkin execution contract is selected and tested. + +The Mapping tab must not hard-code NodeMap names. Until the ParsingMap-aware bridge/service contract is implemented, the NodeMap selector remains unavailable and the existing Mapping get/put/resolve controls remain as compatibility controls. Do not create a fake ParsingMap in Swing. + +The Terminal tab currently shows Workbench UI activity only. Actual worker log streaming/filtering is a separate phase and must not be implemented by redirecting MCP stdout. The Diagnostic Log Explorer is also a placeholder until it is bound to Pickleball's existing retained diagnostic artifacts and evidence-escalation model. Do not populate either tab with fake production data. -The UI covers project/synchronization status, worker lifecycle, live raw Gherkin, Mapping get/put/resolve, semantic events, Step Override list/compile/remove/clear, browser page/screenshot evidence, service-call evidence, and semantic breakpoint list/add/remove/clear. +Existing capabilities remain available: project/synchronization status, worker lifecycle, live raw Gherkin, Mapping get/put/resolve, semantic events, Step Override list/compile/remove/clear, browser page/screenshot evidence, service-call evidence, and semantic breakpoint list/add/remove/clear. The Swing Mapping put control sends entered values as text; it does not create a second Mapping parser or state model. Step Override source is sent unchanged to worker-side compilation and must contain `{{CLASS_NAME}}`; the UI must never compile handlers in the controller JVM. Browser/service/screenshot controls only present bridge evidence already supplied by Pickleball. Breakpoint controls delegate the hook/filter/lease contract to the shared service and must not recreate coordinator semantics. -Blocking synchronization, process, bridge, Mapping, event, screenshot, service-call, Step Override, and breakpoint actions must not run on the Swing Event Dispatch Thread. Live controls must target the controller-owned running/paused worker. Semantic-event cursors are worker-local and must reset when a fresh worker is started/restarted. Prefer headless-safe tests around presentation/controller delegation rather than tests requiring a visible desktop. +Blocking synchronization, process, bridge, Mapping, event, screenshot, service-call, Step Override, and breakpoint actions must not run on the Swing Event Dispatch Thread. Live controls must target the controller-owned running/paused worker. Semantic-event cursors are worker-local and must reset when a fresh worker is started/restarted. Prefer headless-safe tests around player state and presentation/controller delegation rather than tests requiring a visible desktop. ## MCP stdio diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java new file mode 100644 index 00000000..baca65ac --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java @@ -0,0 +1,255 @@ +package tools.dscode.workbench.player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Headless presentation model for the Workbench live scenario buffer. + * + *

This class owns only interactive-buffer state. It does not parse or execute + * Pickleball steps, mutate Mapping state, or implement runtime rewind semantics.

+ */ +public final class LiveScenarioPlayer { + public enum State { + STOPPED, + PAUSED, + RUNNING, + WAITING_FOR_STEP + } + + public enum LineType { + STRUCTURE, + STEP, + COMMENT, + BLANK, + TEXT + } + + public enum ExecutionStatus { + NONE, + PENDING, + EXECUTED, + FAILED + } + + public record Line(long id, String text, LineType type, ExecutionStatus executionStatus) { + public Line { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(executionStatus, "executionStatus"); + } + + public boolean executable() { + return type == LineType.STEP; + } + } + + private final List lines = new ArrayList<>(); + private long nextId = 1; + private Long selectedId; + private int playheadIndex; + private State state = State.STOPPED; + + public LiveScenarioPlayer(List initialLines) { + if (initialLines != null) { + for (String text : initialLines) { + addInitialLine(text == null ? "" : text); + } + } + playheadIndex = findNextExecutableIndex(0); + } + + public static LiveScenarioPlayer interactiveBuffer() { + return new LiveScenarioPlayer(List.of( + "Feature: Workbench Live Scenario", + "", + "Scenario: Interactive session", + "", + "# Enter a live Gherkin step in the Step Editor below." + )); + } + + public List lines() { + return List.copyOf(lines); + } + + public State state() { + return state; + } + + public OptionalLong selectedId() { + return selectedId == null ? OptionalLong.empty() : OptionalLong.of(selectedId); + } + + public Optional selectedLine() { + if (selectedId == null) return Optional.empty(); + return lines.stream().filter(line -> line.id() == selectedId).findFirst(); + } + + public Optional nextStep() { + if (playheadIndex >= lines.size()) return Optional.empty(); + Line line = lines.get(playheadIndex); + return line.executable() ? Optional.of(line) : Optional.empty(); + } + + /** Returns the display index of the next executable step, or {@code lines().size()} at end-of-buffer. */ + public int playheadIndex() { + return playheadIndex; + } + + public void select(long id) { + requireLineIndex(id); + selectedId = id; + } + + public void clearSelection() { + selectedId = null; + } + + /** + * Inserts a new live command at the playhead insertion point. + * The inserted line receives a stable id and becomes the next executable step. + */ + public Line insertStep(String text) { + String stepText = requiredText(text, "Step"); + int insertAt = Math.min(playheadIndex, lines.size()); + Line inserted = new Line(nextId++, stepText, LineType.STEP, ExecutionStatus.PENDING); + lines.add(insertAt, inserted); + playheadIndex = insertAt; + if (state == State.WAITING_FOR_STEP) state = State.RUNNING; + return inserted; + } + + /** + * Updates the selected pending buffer step while preserving its durable id. + * Already executed/failed steps are intentionally not editable because this + * presentation model does not imply runtime rewind or side-effect rollback. + */ + public Line updateSelectedStep(String text) { + String stepText = requiredText(text, "Step"); + Line selected = selectedLine().orElseThrow(() -> + new IllegalStateException("Select an executable pending step to update.")); + if (!selected.executable() || selected.executionStatus() != ExecutionStatus.PENDING) { + throw new IllegalStateException("Only pending executable steps can be updated."); + } + int index = requireLineIndex(selected.id()); + Line updated = new Line(selected.id(), stepText, LineType.STEP, ExecutionStatus.PENDING); + lines.set(index, updated); + return updated; + } + + public void play() { + state = nextStep().isPresent() ? State.RUNNING : State.WAITING_FOR_STEP; + } + + public void pause() { + if (state == State.RUNNING || state == State.WAITING_FOR_STEP) { + state = State.PAUSED; + } + } + + public void stop() { + state = State.STOPPED; + } + + /** Isolated execution always leaves the main live player paused. */ + public void pauseForIsolatedExecution() { + state = State.PAUSED; + } + + /** Marks the current playhead step executed and advances to the next executable buffer line. */ + public void markCurrentStepExecuted(long stepId) { + int index = requireCurrentStep(stepId); + Line current = lines.get(index); + lines.set(index, new Line(current.id(), current.text(), current.type(), ExecutionStatus.EXECUTED)); + playheadIndex = findNextExecutableIndex(index + 1); + if (state == State.RUNNING && playheadIndex >= lines.size()) { + state = State.WAITING_FOR_STEP; + } + } + + /** Marks the current playhead step failed and pauses without advancing it. */ + public void markCurrentStepFailed(long stepId) { + int index = requireCurrentStep(stepId); + Line current = lines.get(index); + lines.set(index, new Line(current.id(), current.text(), current.type(), ExecutionStatus.FAILED)); + playheadIndex = index; + state = State.PAUSED; + } + + /** Navigation only. Does not reset execution status or claim to undo runtime side effects. */ + public void movePlayheadToFirstStep() { + playheadIndex = findNextExecutableIndex(0); + } + + /** Navigation only. Does not reset execution status or claim to undo runtime side effects. */ + public void movePlayheadToPreviousStep() { + int from = Math.min(playheadIndex - 1, lines.size() - 1); + for (int i = from; i >= 0; i--) { + if (lines.get(i).executable()) { + playheadIndex = i; + return; + } + } + movePlayheadToFirstStep(); + } + + private void addInitialLine(String text) { + LineType type = classify(text); + ExecutionStatus status = type == LineType.STEP ? ExecutionStatus.PENDING : ExecutionStatus.NONE; + lines.add(new Line(nextId++, text, type, status)); + } + + private int findNextExecutableIndex(int from) { + for (int i = Math.max(0, from); i < lines.size(); i++) { + if (lines.get(i).executable()) return i; + } + return lines.size(); + } + + private int requireCurrentStep(long id) { + if (playheadIndex >= lines.size() || !lines.get(playheadIndex).executable() + || lines.get(playheadIndex).id() != id) { + throw new IllegalStateException("Step " + id + " is not the current playhead step."); + } + return playheadIndex; + } + + private int requireLineIndex(long id) { + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).id() == id) return i; + } + throw new IllegalArgumentException("Unknown live scenario line id: " + id); + } + + private static String requiredText(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + return value.strip(); + } + + private static LineType classify(String text) { + String trimmed = text.stripLeading(); + if (trimmed.isBlank()) return LineType.BLANK; + if (trimmed.startsWith("#")) return LineType.COMMENT; + if (startsWithAny(trimmed, + "Feature:", "Rule:", "Background:", "Scenario:", "Scenario Outline:", "Examples:")) { + return LineType.STRUCTURE; + } + if (startsWithAny(trimmed, "Given ", "When ", "Then ", "And ", "But ", "* ")) { + return LineType.STEP; + } + return LineType.TEXT; + } + + private static boolean startsWithAny(String value, String... prefixes) { + for (String prefix : prefixes) { + if (value.startsWith(prefix)) return true; + } + return false; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index 6d3eff63..19eb60a3 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -1,66 +1,83 @@ package tools.dscode.workbench.ui; +import tools.dscode.workbench.player.LiveScenarioPlayer; + import javax.swing.*; +import javax.swing.border.EmptyBorder; import java.awt.*; +import java.awt.event.KeyEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.util.concurrent.ExecutionException; import java.util.function.Consumer; import java.util.function.Supplier; -/** Small execution-oriented Swing shell for Workbench lifecycle and live interaction. */ +/** Player-style Swing presentation adapter over the shared Workbench service seam. */ final class WorkbenchFrame extends JFrame { private final WorkbenchUiController controller; - private final JButton syncButton = new JButton("Synchronize"); - private final JButton refreshButton = new JButton("Refresh"); - private final JButton startButton = new JButton("Start Worker"); - private final JButton restartButton = new JButton("Restart Worker"); - private final JButton stopButton = new JButton("Stop Worker"); + private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + + private final DefaultListModel scenarioModel = new DefaultListModel<>(); + private final JList scenarioList = new JList<>(scenarioModel); + private final JTextField stepText = new JTextField(); + + private final JButton firstButton = playerButton("⏮", "First step (navigation only; does not undo runtime side effects)"); + private final JButton backButton = playerButton("◀", "Previous step (navigation only; does not undo runtime side effects)"); + private final JButton playButton = playerButton("▶", "Phase 1 player-state control; runtime run loop is added in Phase 2"); + private final JButton pauseButton = playerButton("⏸", "Pause player advancement at the next safe boundary"); + private final JButton playerStopButton = playerButton("■", "Stop the live player state"); + private final JButton isolatedStepButton = smallPlayerButton("▶", "Execute the Step Editor text in isolation"); + + private final JLabel projectLabel = new JLabel("Project: loading..."); + private final JLabel readinessLabel = new JLabel("Loading status..."); + private final JLabel playerStatusLabel = new JLabel("Stopped"); + private final JLabel activityLabel = new JLabel("Ready"); + + private final JMenuItem syncItem = new JMenuItem("Synchronize"); + private final JMenuItem refreshItem = new JMenuItem("Refresh Status"); + private final JMenuItem startItem = new JMenuItem("Start Worker"); + private final JMenuItem restartItem = new JMenuItem("Restart Worker"); + private final JMenuItem stopItem = new JMenuItem("Stop Worker"); - private final JButton executeStepButton = new JButton("Execute Step"); private final JButton mappingGetButton = new JButton("Get"); private final JButton mappingPutButton = new JButton("Put"); private final JButton mappingResolveButton = new JButton("Resolve"); - private final JButton eventsRefreshButton = new JButton("Refresh Events"); - - private final JButton overrideCompileButton = new JButton("Compile / Replace"); - private final JButton overrideRefreshButton = new JButton("Refresh List"); - private final JButton overrideRemoveButton = new JButton("Remove ID"); - private final JButton overrideClearButton = new JButton("Clear All"); - - private final JButton browserPageButton = new JButton("Read Page"); - private final JButton browserScreenshotButton = new JButton("Capture Screenshot"); - private final JButton serviceCallButton = new JButton("Execute Service Call"); - - private final JButton breakpointAddButton = new JButton("Add"); - private final JButton breakpointRefreshButton = new JButton("Refresh List"); - private final JButton breakpointRemoveButton = new JButton("Remove ID"); - private final JButton breakpointClearButton = new JButton("Clear All"); - - private final JTextArea statusArea = outputArea(); - private final JTextField stepText = new JTextField("CONTROL API TEST STEP"); - private final JTextArea stepArgument = new JTextArea(4, 60); - private final JTextArea liveOutput = outputArea(); - + private final JComboBox nodeMapSelector = new JComboBox<>(); private final JTextField mappingReference = new JTextField("OVERRIDE"); private final JTextField mappingKey = new JTextField("workbenchLiveValue"); private final JTextField mappingValue = new JTextField("first"); private final JTextField mappingInput = new JTextField(""); private final JTextArea mappingOutput = outputArea(); + + private final JTextArea terminalArea = outputArea(); + + private final JTextArea statusArea = outputArea(); + private final JButton eventsRefreshButton = new JButton("Refresh Events"); private final JTextArea eventsArea = outputArea(); + private final JButton overrideCompileButton = new JButton("Compile / Replace"); + private final JButton overrideRefreshButton = new JButton("Refresh List"); + private final JButton overrideRemoveButton = new JButton("Remove ID"); + private final JButton overrideClearButton = new JButton("Clear All"); private final JTextField overrideId = new JTextField("workbench-ui-generated"); private final JTextField overrideRegex = new JTextField("^WORKBENCH UI OVERRIDE ([A-Za-z]+)$"); private final JTextArea overrideSource = new JTextArea(defaultOverrideSource(), 16, 70); private final JTextArea overrideOutput = outputArea(); private final JTextArea overrideList = outputArea(); + private final JButton browserPageButton = new JButton("Read Page"); + private final JButton browserScreenshotButton = new JButton("Capture Screenshot"); private final JTextArea browserOutput = outputArea(); private final JTabbedPane browserEvidenceTabs = new JTabbedPane(); private final JLabel screenshotLabel = new JLabel("No screenshot captured.", SwingConstants.CENTER); + private final JButton serviceCallButton = new JButton("Execute Service Call"); private final JTextField serviceSelector = new JTextField("%health-full-url"); private final JTextArea serviceOutput = outputArea(); + private final JButton breakpointAddButton = new JButton("Add"); + private final JButton breakpointRefreshButton = new JButton("Refresh List"); + private final JButton breakpointRemoveButton = new JButton("Remove ID"); + private final JButton breakpointClearButton = new JButton("Clear All"); private final JTextField breakpointId = new JTextField(); private final JTextField breakpointHook = new JTextField("BEFORE_STEP"); private final JTextField breakpointSignature = new JTextField(); @@ -71,8 +88,9 @@ final class WorkbenchFrame extends JFrame { private final JTextArea breakpointOutput = outputArea(); private final JTextArea breakpointList = outputArea(); - private final JLabel activityLabel = new JLabel("Ready"); private WorkbenchUiController.State lastState; + private JDialog advancedDialog; + private boolean syncingScenarioSelection; private boolean closing; WorkbenchFrame(WorkbenchUiController controller) { @@ -80,41 +98,281 @@ final class WorkbenchFrame extends JFrame { this.controller = controller; setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); - setMinimumSize(new Dimension(900, 650)); - setSize(1080, 780); + setMinimumSize(new Dimension(1100, 720)); + setSize(1480, 900); setLocationByPlatform(true); + setJMenuBar(menuBar()); + + JPanel root = new JPanel(new BorderLayout(8, 8)); + root.setBorder(new EmptyBorder(8, 8, 8, 8)); + root.add(playerBar(), BorderLayout.NORTH); + + JSplitPane workspace = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftWorkspace(), rightWorkspace()); + workspace.setResizeWeight(0.52); + workspace.setDividerLocation(760); + root.add(workspace, BorderLayout.CENTER); + root.add(footer(), BorderLayout.SOUTH); + setContentPane(root); + + wirePlayerActions(); + wireRuntimeActions(); + configureScenarioEditor(); + configureStepEditor(); + syncScenarioView(); + updatePlayerView(null); - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(syncButton); - actions.add(refreshButton); - actions.add(startButton); - actions.add(restartButton); - actions.add(stopButton); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent event) { + closeWorkbench(); + } + }); + + runStateAction("Loading project status", controller::refresh); + } + + private JMenuBar menuBar() { + JMenuBar bar = new JMenuBar(); + + JMenu file = new JMenu("File"); + JMenuItem exit = new JMenuItem("Exit"); + exit.addActionListener(event -> closeWorkbench()); + file.add(exit); + bar.add(file); + + JMenu session = new JMenu("Session"); + session.add(syncItem); + session.add(refreshItem); + session.addSeparator(); + session.add(startItem); + session.add(restartItem); + session.add(stopItem); + bar.add(session); + + JMenu tools = new JMenu("Tools"); + JMenuItem advanced = new JMenuItem("Advanced Controls..."); + advanced.addActionListener(event -> showAdvancedControls()); + tools.add(advanced); + bar.add(tools); + + return bar; + } + + private JPanel playerBar() { + JPanel bar = new JPanel(new BorderLayout(12, 0)); + + JPanel project = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); + project.add(projectLabel); + project.add(readinessLabel); + bar.add(project, BorderLayout.WEST); + + JPanel controls = new JPanel(new FlowLayout(FlowLayout.CENTER, 6, 0)); + controls.add(firstButton); + controls.add(backButton); + controls.add(playButton); + controls.add(pauseButton); + controls.add(playerStopButton); + controls.add(Box.createHorizontalStrut(8)); + controls.add(new JLabel("Speed:")); + JComboBox speed = new JComboBox<>(new String[]{"0.5x", "1.0x", "2.0x"}); + speed.setSelectedItem("1.0x"); + speed.setEnabled(false); + speed.setToolTipText("Runtime playback speed is introduced with the Phase 2 execution loop."); + controls.add(speed); + bar.add(controls, BorderLayout.CENTER); + + JPanel state = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + state.add(new JLabel("Status:")); + state.add(playerStatusLabel); + bar.add(state, BorderLayout.EAST); + return bar; + } + + private JComponent leftWorkspace() { + JPanel left = new JPanel(new BorderLayout(0, 8)); + left.add(scenarioPanel(), BorderLayout.CENTER); + left.add(stepPanel(), BorderLayout.SOUTH); + return left; + } + + private JComponent scenarioPanel() { + JPanel panel = new JPanel(new BorderLayout(0, 4)); + panel.setBorder(BorderFactory.createTitledBorder("Live Scenario Editor")); + + scenarioList.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + scenarioList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + scenarioList.setFixedCellHeight(26); + scenarioList.setCellRenderer(new ScenarioRenderer()); + panel.add(new JScrollPane(scenarioList), BorderLayout.CENTER); + + JPanel legend = new JPanel(new FlowLayout(FlowLayout.LEFT, 18, 2)); + legend.add(new JLabel("▶ Playhead")); + legend.add(new JLabel("Selected line = highlight")); + legend.add(new JLabel("Executed = dimmed")); + legend.add(new JLabel("Failed = red")); + panel.add(legend, BorderLayout.SOUTH); + return panel; + } + + private JComponent stepPanel() { + JPanel panel = new JPanel(new BorderLayout(6, 5)); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEtchedBorder(), + new EmptyBorder(5, 7, 7, 7) + )); + + JPanel header = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + JLabel title = new JLabel("Step Editor / Command"); + title.setFont(title.getFont().deriveFont(Font.BOLD)); + header.add(title); + header.add(isolatedStepButton); + panel.add(header, BorderLayout.NORTH); + stepText.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + stepText.setToolTipText("Enter inserts ahead of the playhead. Ctrl+Enter updates the selected pending step."); + panel.add(stepText, BorderLayout.CENTER); + return panel; + } + + private JComponent rightWorkspace() { JTabbedPane tabs = new JTabbedPane(); - tabs.addTab("Status", new JScrollPane(statusArea)); - tabs.addTab("Live Gherkin", livePanel()); tabs.addTab("Mapping", mappingPanel()); - tabs.addTab("Recent Events", eventsPanel()); - tabs.addTab("Step Overrides", stepOverridePanel()); - tabs.addTab("Evidence", evidencePanel()); - tabs.addTab("Breakpoints", breakpointPanel()); - - add(actions, BorderLayout.NORTH); - add(tabs, BorderLayout.CENTER); - add(activityLabel, BorderLayout.SOUTH); - - syncButton.addActionListener(event -> runStateAction("Synchronizing project", controller::synchronize)); - refreshButton.addActionListener(event -> runStateAction("Refreshing status", controller::refresh)); - startButton.addActionListener(event -> runStateAction("Starting worker", controller::startWorker)); - restartButton.addActionListener(event -> runStateAction("Restarting worker", controller::restartWorker)); - stopButton.addActionListener(event -> runStateAction("Stopping worker", controller::stopWorker)); - - executeStepButton.addActionListener(event -> runLiveAction( - "Executing live Gherkin", - () -> controller.executeStep(stepText.getText(), stepArgument.getText()), - liveOutput::setText - )); + tabs.addTab("Terminal", terminalPanel()); + tabs.addTab("Diagnostic Log Explorer", diagnosticsPanel()); + return tabs; + } + + private JPanel mappingPanel() { + JPanel panel = new JPanel(new BorderLayout(6, 6)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + + JPanel selector = new JPanel(new BorderLayout(6, 0)); + selector.add(new JLabel("NodeMap:"), BorderLayout.WEST); + nodeMapSelector.setEnabled(false); + nodeMapSelector.setToolTipText("Populated from the selected step ParsingMap after the Phase 3 bridge contract is added."); + selector.add(nodeMapSelector, BorderLayout.CENTER); + JLabel pending = new JLabel("ParsingMap inspection API required"); + pending.setForeground(Color.GRAY); + selector.add(pending, BorderLayout.EAST); + panel.add(selector, BorderLayout.NORTH); + + JPanel legacy = new JPanel(new BorderLayout(6, 6)); + legacy.setBorder(BorderFactory.createTitledBorder("Existing Mapping operations")); + + JPanel fields = new JPanel(new GridLayout(4, 2, 6, 6)); + fields.add(new JLabel("Mapping reference")); + fields.add(mappingReference); + fields.add(new JLabel("Key")); + fields.add(mappingKey); + fields.add(new JLabel("Value (text)")); + fields.add(mappingValue); + fields.add(new JLabel("Resolve input")); + fields.add(mappingInput); + legacy.add(fields, BorderLayout.NORTH); + + JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); + actions.add(mappingGetButton); + actions.add(mappingPutButton); + actions.add(mappingResolveButton); + legacy.add(actions, BorderLayout.CENTER); + legacy.add(new JScrollPane(mappingOutput), BorderLayout.SOUTH); + mappingOutput.setRows(10); + + panel.add(legacy, BorderLayout.CENTER); + return panel; + } + + private JPanel terminalPanel() { + JPanel panel = new JPanel(new BorderLayout(6, 6)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + + JPanel top = new JPanel(new BorderLayout(6, 0)); + JLabel note = new JLabel("Workbench activity; worker log streaming is introduced in Phase 4."); + note.setForeground(Color.GRAY); + top.add(note, BorderLayout.CENTER); + JButton clear = new JButton("Clear"); + clear.addActionListener(event -> terminalArea.setText("")); + top.add(clear, BorderLayout.EAST); + panel.add(top, BorderLayout.NORTH); + panel.add(new JScrollPane(terminalArea), BorderLayout.CENTER); + return panel; + } + + private JPanel diagnosticsPanel() { + JPanel panel = new JPanel(new BorderLayout()); + panel.setBorder(new EmptyBorder(16, 16, 16, 16)); + JTextArea message = outputArea(); + message.setText(""" + Diagnostic Log Explorer foundation + + Phase 5 will bind this tab to Pickleball's retained diagnostic artifacts using the existing + run-catalog -> run-index/clusters -> summary -> events -> visual evidence escalation model. + + No fake run catalog or competing diagnostic storage is created by the Swing UI. + """); + message.setCaretPosition(0); + panel.add(new JScrollPane(message), BorderLayout.CENTER); + return panel; + } + + private void configureScenarioEditor() { + scenarioList.addListSelectionListener(event -> { + if (event.getValueIsAdjusting() || syncingScenarioSelection) return; + LiveScenarioPlayer.Line selected = scenarioList.getSelectedValue(); + if (selected == null) { + player.clearSelection(); + return; + } + player.select(selected.id()); + if (selected.executable()) stepText.setText(selected.text()); + }); + } + + private void configureStepEditor() { + stepText.addActionListener(event -> insertStep()); + stepText.getInputMap(JComponent.WHEN_FOCUSED).put( + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, KeyEvent.CTRL_DOWN_MASK), + "update-selected-step" + ); + stepText.getActionMap().put("update-selected-step", new AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent event) { + updateSelectedStep(); + } + }); + } + + private void wirePlayerActions() { + firstButton.addActionListener(event -> { + player.movePlayheadToFirstStep(); + updatePlayerView("Playhead moved to first step. Navigation does not undo runtime side effects."); + }); + backButton.addActionListener(event -> { + player.movePlayheadToPreviousStep(); + updatePlayerView("Playhead moved back. Navigation does not undo runtime side effects."); + }); + playButton.addActionListener(event -> { + player.play(); + updatePlayerView("Player state updated. Buffered automatic execution is implemented in Phase 2."); + }); + pauseButton.addActionListener(event -> { + player.pause(); + updatePlayerView("Player paused."); + }); + playerStopButton.addActionListener(event -> { + player.stop(); + updatePlayerView("Player stopped. Worker lifecycle remains available from Session."); + }); + isolatedStepButton.addActionListener(event -> executeIsolatedStep()); + } + + private void wireRuntimeActions() { + syncItem.addActionListener(event -> runStateAction("Synchronizing project", controller::synchronize)); + refreshItem.addActionListener(event -> runStateAction("Refreshing status", controller::refresh)); + startItem.addActionListener(event -> runStateAction("Starting worker", controller::startWorker)); + restartItem.addActionListener(event -> runStateAction("Restarting worker", controller::restartWorker)); + stopItem.addActionListener(event -> runStateAction("Stopping worker", controller::stopWorker)); + mappingGetButton.addActionListener(event -> runLiveAction( "Reading Mapping value", () -> controller.mappingGet(mappingReference.getText(), mappingKey.getText()), @@ -138,145 +396,142 @@ final class WorkbenchFrame extends JFrame { overrideCompileButton.addActionListener(event -> runManagementAction( "Compiling Step Override", - () -> controller.compileStepOverride( - overrideId.getText(), overrideRegex.getText(), overrideSource.getText() - ), + () -> controller.compileStepOverride(overrideId.getText(), overrideRegex.getText(), overrideSource.getText()), overrideOutput, overrideList )); overrideRefreshButton.addActionListener(event -> runTextAction( - "Refreshing Step Overrides", - controller::stepOverrides, - overrideList::setText - )); + "Refreshing Step Overrides", controller::stepOverrides, overrideList::setText)); overrideRemoveButton.addActionListener(event -> runManagementAction( - "Removing Step Override", - () -> controller.removeStepOverride(overrideId.getText()), - overrideOutput, - overrideList - )); + "Removing Step Override", () -> controller.removeStepOverride(overrideId.getText()), overrideOutput, overrideList)); overrideClearButton.addActionListener(event -> runManagementAction( - "Clearing Step Overrides", - controller::clearStepOverrides, - overrideOutput, - overrideList - )); + "Clearing Step Overrides", controller::clearStepOverrides, overrideOutput, overrideList)); browserPageButton.addActionListener(event -> runLiveAction( - "Reading browser page evidence", - controller::browserPage, - browserOutput::setText - )); + "Reading browser page evidence", controller::browserPage, browserOutput::setText)); browserScreenshotButton.addActionListener(event -> runBackground( - "Capturing browser screenshot", - controller::browserScreenshot, - this::applyScreenshot - )); + "Capturing browser screenshot", controller::browserScreenshot, this::applyScreenshot)); serviceCallButton.addActionListener(event -> runLiveAction( - "Executing service call", - () -> controller.serviceCall(serviceSelector.getText()), - serviceOutput::setText - )); + "Executing service call", () -> controller.serviceCall(serviceSelector.getText()), serviceOutput::setText)); breakpointAddButton.addActionListener(event -> runManagementAction( "Adding breakpoint", () -> controller.addBreakpoint( - breakpointHook.getText(), - breakpointSignature.getText(), - breakpointStep.getText(), - breakpointPhrase.getText(), - breakpointOneShot.isSelected(), - breakpointLease.getText() - ), + breakpointHook.getText(), breakpointSignature.getText(), breakpointStep.getText(), + breakpointPhrase.getText(), breakpointOneShot.isSelected(), breakpointLease.getText()), breakpointOutput, breakpointList )); breakpointRefreshButton.addActionListener(event -> runTextAction( - "Refreshing breakpoints", - controller::breakpoints, - breakpointList::setText - )); + "Refreshing breakpoints", controller::breakpoints, breakpointList::setText)); breakpointRemoveButton.addActionListener(event -> runManagementAction( - "Removing breakpoint", - () -> controller.removeBreakpoint(breakpointId.getText()), - breakpointOutput, - breakpointList - )); + "Removing breakpoint", () -> controller.removeBreakpoint(breakpointId.getText()), breakpointOutput, breakpointList)); breakpointClearButton.addActionListener(event -> runManagementAction( - "Clearing breakpoints", - controller::clearBreakpoints, - breakpointOutput, - breakpointList - )); - - addWindowListener(new WindowAdapter() { - @Override - public void windowClosing(WindowEvent event) { - closeWorkbench(); - } - }); - - runStateAction("Loading project status", controller::refresh); + "Clearing breakpoints", controller::clearBreakpoints, breakpointOutput, breakpointList)); } - private JPanel livePanel() { - JPanel input = new JPanel(new BorderLayout(6, 6)); - input.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + private void insertStep() { + try { + LiveScenarioPlayer.Line inserted = player.insertStep(stepText.getText()); + syncScenarioView(); + appendTerminal("Inserted live buffer step #" + inserted.id() + ": " + inserted.text()); + updatePlayerView(null); + } catch (RuntimeException failure) { + showFailure("Insert step", failure); + } + } - JPanel stepLine = new JPanel(new BorderLayout(6, 6)); - stepLine.add(new JLabel("Step"), BorderLayout.WEST); - stepLine.add(stepText, BorderLayout.CENTER); - stepLine.add(executeStepButton, BorderLayout.EAST); + private void updateSelectedStep() { + try { + LiveScenarioPlayer.Line updated = player.updateSelectedStep(stepText.getText()); + syncScenarioView(); + appendTerminal("Updated pending live buffer step #" + updated.id() + ": " + updated.text()); + updatePlayerView(null); + } catch (RuntimeException failure) { + showFailure("Update selected step", failure); + } + } - JPanel argument = new JPanel(new BorderLayout(6, 6)); - argument.add(new JLabel("Optional argument"), BorderLayout.NORTH); - argument.add(new JScrollPane(stepArgument), BorderLayout.CENTER); + private void executeIsolatedStep() { + String text = stepText.getText(); + if (text == null || text.isBlank()) { + showFailure("Execute isolated step", new IllegalArgumentException("Gherkin step must not be blank.")); + return; + } + player.pauseForIsolatedExecution(); + updatePlayerView("Main player paused for isolated execution."); + runLiveAction( + "Executing isolated Gherkin", + () -> controller.executeStep(text, ""), + output -> appendTerminal("Isolated step\n" + output) + ); + } - input.add(stepLine, BorderLayout.NORTH); - input.add(argument, BorderLayout.CENTER); + private void syncScenarioView() { + Long selectedId = player.selectedId().isPresent() ? player.selectedId().getAsLong() : null; + syncingScenarioSelection = true; + try { + scenarioModel.clear(); + int selectedIndex = -1; + int index = 0; + for (LiveScenarioPlayer.Line line : player.lines()) { + scenarioModel.addElement(line); + if (selectedId != null && line.id() == selectedId) selectedIndex = index; + index++; + } + if (selectedIndex >= 0) scenarioList.setSelectedIndex(selectedIndex); + } finally { + syncingScenarioSelection = false; + } + scenarioList.repaint(); + } - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, input, new JScrollPane(liveOutput)); - split.setResizeWeight(0.45); - split.setBorder(null); + private void updatePlayerView(String activity) { + playerStatusLabel.setText(switch (player.state()) { + case STOPPED -> "Stopped"; + case PAUSED -> "Paused"; + case RUNNING -> "Running"; + case WAITING_FOR_STEP -> "Waiting for next step..."; + }); + if (activity != null && !activity.isBlank()) { + activityLabel.setText(activity); + appendTerminal(activity); + } + scenarioList.repaint(); + restoreControls(); + } - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); + private JPanel footer() { + JPanel panel = new JPanel(new BorderLayout(8, 0)); + panel.add(activityLabel, BorderLayout.WEST); + JLabel hint = new JLabel("Session lifecycle: Session menu • Existing investigation tools: Tools > Advanced Controls"); + hint.setForeground(Color.GRAY); + panel.add(hint, BorderLayout.EAST); return panel; } - private JPanel mappingPanel() { - JPanel fields = new JPanel(new GridLayout(4, 2, 6, 6)); - fields.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - fields.add(new JLabel("Mapping reference")); - fields.add(mappingReference); - fields.add(new JLabel("Key")); - fields.add(mappingKey); - fields.add(new JLabel("Value (text)")); - fields.add(mappingValue); - fields.add(new JLabel("Resolve input")); - fields.add(mappingInput); - - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(mappingGetButton); - actions.add(mappingPutButton); - actions.add(mappingResolveButton); - - JPanel controls = new JPanel(new BorderLayout()); - controls.add(fields, BorderLayout.CENTER); - controls.add(actions, BorderLayout.SOUTH); - - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, controls, new JScrollPane(mappingOutput)); - split.setResizeWeight(0.42); - split.setBorder(null); - - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); - return panel; + private void showAdvancedControls() { + if (advancedDialog == null) { + advancedDialog = new JDialog(this, "Workbench Advanced Controls", false); + advancedDialog.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE); + advancedDialog.setSize(980, 720); + advancedDialog.setLocationRelativeTo(this); + + JTabbedPane tabs = new JTabbedPane(); + tabs.addTab("Status", new JScrollPane(statusArea)); + tabs.addTab("Recent Events", eventsPanel()); + tabs.addTab("Step Overrides", stepOverridePanel()); + tabs.addTab("Evidence", evidencePanel()); + tabs.addTab("Breakpoints", breakpointPanel()); + advancedDialog.setContentPane(tabs); + } + advancedDialog.setVisible(true); + advancedDialog.toFront(); } private JPanel eventsPanel() { JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); panel.add(eventsRefreshButton, BorderLayout.NORTH); panel.add(new JScrollPane(eventsArea), BorderLayout.CENTER); return panel; @@ -290,7 +545,7 @@ private JPanel stepOverridePanel() { fields.add(overrideRegex); JPanel source = new JPanel(new BorderLayout(6, 6)); - source.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + source.setBorder(new EmptyBorder(8, 8, 8, 8)); source.add(fields, BorderLayout.NORTH); source.add(new JScrollPane(overrideSource), BorderLayout.CENTER); @@ -301,26 +556,23 @@ private JPanel stepOverridePanel() { actions.add(overrideClearButton); source.add(actions, BorderLayout.SOUTH); - JTabbedPane outputTabs = new JTabbedPane(); - outputTabs.addTab("Result", new JScrollPane(overrideOutput)); - outputTabs.addTab("Installed", new JScrollPane(overrideList)); + JTabbedPane outputs = new JTabbedPane(); + outputs.addTab("Result", new JScrollPane(overrideOutput)); + outputs.addTab("Installed", new JScrollPane(overrideList)); - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, source, outputTabs); + JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, source, outputs); split.setResizeWeight(0.62); - split.setBorder(null); - JPanel panel = new JPanel(new BorderLayout()); panel.add(split, BorderLayout.CENTER); return panel; } private JPanel evidencePanel() { - JTabbedPane evidenceTabs = new JTabbedPane(); - evidenceTabs.addTab("Browser", browserEvidencePanel()); - evidenceTabs.addTab("Service Call", serviceEvidencePanel()); - + JTabbedPane tabs = new JTabbedPane(); + tabs.addTab("Browser", browserEvidencePanel()); + tabs.addTab("Service Call", serviceEvidencePanel()); JPanel panel = new JPanel(new BorderLayout()); - panel.add(evidenceTabs, BorderLayout.CENTER); + panel.add(tabs, BorderLayout.CENTER); return panel; } @@ -338,7 +590,7 @@ private JPanel browserEvidencePanel() { browserEvidenceTabs.addTab("Screenshot", screenshotScroll); JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); panel.add(actions, BorderLayout.NORTH); panel.add(browserEvidenceTabs, BorderLayout.CENTER); return panel; @@ -351,7 +603,7 @@ private JPanel serviceEvidencePanel() { controls.add(serviceCallButton, BorderLayout.EAST); JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); panel.add(controls, BorderLayout.NORTH); panel.add(new JScrollPane(serviceOutput), BorderLayout.CENTER); return panel; @@ -375,9 +627,8 @@ private JPanel breakpointPanel() { fields.add(breakpointOneShot); JPanel controls = new JPanel(new BorderLayout()); - controls.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + controls.setBorder(new EmptyBorder(8, 8, 8, 8)); controls.add(fields, BorderLayout.CENTER); - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); actions.add(breakpointAddButton); actions.add(breakpointRefreshButton); @@ -385,14 +636,12 @@ private JPanel breakpointPanel() { actions.add(breakpointClearButton); controls.add(actions, BorderLayout.SOUTH); - JTabbedPane outputTabs = new JTabbedPane(); - outputTabs.addTab("Result", new JScrollPane(breakpointOutput)); - outputTabs.addTab("Installed", new JScrollPane(breakpointList)); + JTabbedPane outputs = new JTabbedPane(); + outputs.addTab("Result", new JScrollPane(breakpointOutput)); + outputs.addTab("Installed", new JScrollPane(breakpointList)); - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, controls, outputTabs); + JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, controls, outputs); split.setResizeWeight(0.5); - split.setBorder(null); - JPanel panel = new JPanel(new BorderLayout()); panel.add(split, BorderLayout.CENTER); return panel; @@ -433,6 +682,7 @@ private void runBackground(String label, Supplier action, Consumer suc if (closing) return; setControlsEnabled(false); activityLabel.setText(label + "..."); + appendTerminal(label + "..."); new SwingWorker() { @Override @@ -445,6 +695,7 @@ protected void done() { try { success.accept(get()); activityLabel.setText(label + " complete."); + appendTerminal(label + " complete."); } catch (InterruptedException failure) { Thread.currentThread().interrupt(); showFailure(label, failure); @@ -459,6 +710,8 @@ protected void done() { private void applyState(WorkbenchUiController.State state) { lastState = state; + projectLabel.setText("Project: " + displayProject(state)); + readinessLabel.setText(readiness(state)); statusArea.setText(state.render()); statusArea.setCaretPosition(0); if (!state.workerRunning()) { @@ -467,6 +720,18 @@ private void applyState(WorkbenchUiController.State state) { } } + private static String displayProject(WorkbenchUiController.State state) { + return state.projectRoot().getFileName() == null + ? state.projectRoot().toString() + : state.projectRoot().getFileName().toString(); + } + + private static String readiness(WorkbenchUiController.State state) { + if (!state.synchronizedProject()) return "Synchronization required"; + if (!state.workerRunning()) return "Synchronized"; + return state.liveReady() ? "Ready" : "Worker running"; + } + private void applyScreenshot(WorkbenchUiController.ScreenshotResult result) { browserOutput.setText(result.output()); appendEvents(result.events()); @@ -487,38 +752,53 @@ private void appendEvents(String text) { eventsArea.setCaretPosition(eventsArea.getDocument().getLength()); } + private void appendTerminal(String text) { + if (text == null || text.isBlank()) return; + if (!terminalArea.getText().isBlank()) terminalArea.append("\n"); + terminalArea.append(text.stripTrailing() + "\n"); + terminalArea.setCaretPosition(terminalArea.getDocument().getLength()); + } + private void showFailure(String label, Throwable failure) { String message = failure == null ? null : failure.getMessage(); - activityLabel.setText(label + " failed: " + ((message == null || message.isBlank()) - ? String.valueOf(failure) - : message)); + String detail = (message == null || message.isBlank()) ? String.valueOf(failure) : message; + activityLabel.setText(label + " failed: " + detail); + appendTerminal(label + " failed: " + detail); } private void restoreControls() { if (closing) return; boolean running = lastState != null && lastState.workerRunning(); boolean liveReady = lastState != null && lastState.liveReady(); - syncButton.setEnabled(!running); - refreshButton.setEnabled(true); - startButton.setEnabled(lastState != null && lastState.synchronizedProject() && !running); - restartButton.setEnabled(running); - stopButton.setEnabled(running); - executeStepButton.setEnabled(liveReady); + syncItem.setEnabled(!running); + refreshItem.setEnabled(true); + startItem.setEnabled(lastState != null && lastState.synchronizedProject() && !running); + restartItem.setEnabled(running); + stopItem.setEnabled(running); + + boolean hasSteps = player.lines().stream().anyMatch(LiveScenarioPlayer.Line::executable); + firstButton.setEnabled(hasSteps); + backButton.setEnabled(hasSteps); + playButton.setEnabled(true); + pauseButton.setEnabled(player.state() == LiveScenarioPlayer.State.RUNNING + || player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP); + playerStopButton.setEnabled(player.state() != LiveScenarioPlayer.State.STOPPED); + isolatedStepButton.setEnabled(liveReady); + stepText.setEnabled(true); + scenarioList.setEnabled(true); + mappingGetButton.setEnabled(liveReady); mappingPutButton.setEnabled(liveReady); mappingResolveButton.setEnabled(liveReady); eventsRefreshButton.setEnabled(liveReady); - overrideCompileButton.setEnabled(liveReady); overrideRefreshButton.setEnabled(liveReady); overrideRemoveButton.setEnabled(liveReady); overrideClearButton.setEnabled(liveReady); - browserPageButton.setEnabled(liveReady); browserScreenshotButton.setEnabled(liveReady); serviceCallButton.setEnabled(liveReady); - breakpointAddButton.setEnabled(liveReady); breakpointRefreshButton.setEnabled(liveReady); breakpointRemoveButton.setEnabled(liveReady); @@ -526,27 +806,32 @@ private void restoreControls() { } private void setControlsEnabled(boolean enabled) { - syncButton.setEnabled(enabled); - refreshButton.setEnabled(enabled); - startButton.setEnabled(enabled); - restartButton.setEnabled(enabled); - stopButton.setEnabled(enabled); + syncItem.setEnabled(enabled); + refreshItem.setEnabled(enabled); + startItem.setEnabled(enabled); + restartItem.setEnabled(enabled); + stopItem.setEnabled(enabled); + + firstButton.setEnabled(enabled); + backButton.setEnabled(enabled); + playButton.setEnabled(enabled); + pauseButton.setEnabled(enabled); + playerStopButton.setEnabled(enabled); + isolatedStepButton.setEnabled(enabled); + stepText.setEnabled(enabled); + scenarioList.setEnabled(enabled); - executeStepButton.setEnabled(enabled); mappingGetButton.setEnabled(enabled); mappingPutButton.setEnabled(enabled); mappingResolveButton.setEnabled(enabled); eventsRefreshButton.setEnabled(enabled); - overrideCompileButton.setEnabled(enabled); overrideRefreshButton.setEnabled(enabled); overrideRemoveButton.setEnabled(enabled); overrideClearButton.setEnabled(enabled); - browserPageButton.setEnabled(enabled); browserScreenshotButton.setEnabled(enabled); serviceCallButton.setEnabled(enabled); - breakpointAddButton.setEnabled(enabled); breakpointRefreshButton.setEnabled(enabled); breakpointRemoveButton.setEnabled(enabled); @@ -583,6 +868,21 @@ protected void done() { }.execute(); } + private static JButton playerButton(String glyph, String tooltip) { + JButton button = new JButton(glyph); + button.setToolTipText(tooltip); + button.setPreferredSize(new Dimension(46, 32)); + return button; + } + + private static JButton smallPlayerButton(String glyph, String tooltip) { + JButton button = new JButton(glyph); + button.setToolTipText(tooltip); + button.setMargin(new Insets(1, 7, 1, 7)); + button.setFocusable(false); + return button; + } + private static JTextArea outputArea() { JTextArea area = new JTextArea(); area.setEditable(false); @@ -610,4 +910,34 @@ public Object execute(StepOverrideContext context) { } """; } + + private final class ScenarioRenderer extends DefaultListCellRenderer { + @Override + public Component getListCellRendererComponent( + JList list, + Object value, + int index, + boolean selected, + boolean focus + ) { + JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, selected, focus); + LiveScenarioPlayer.Line line = (LiveScenarioPlayer.Line) value; + boolean playhead = index == player.playheadIndex(); + label.setText("%s%3d %s".formatted(playhead ? "▶ " : " ", index + 1, line.text())); + + if (!selected) { + if (line.executionStatus() == LiveScenarioPlayer.ExecutionStatus.EXECUTED) { + label.setForeground(Color.GRAY); + } else if (line.executionStatus() == LiveScenarioPlayer.ExecutionStatus.FAILED) { + label.setForeground(new Color(180, 55, 55)); + } else if (playhead) { + label.setForeground(new Color(40, 140, 70)); + } else if (line.type() == LiveScenarioPlayer.LineType.COMMENT) { + label.setForeground(Color.GRAY); + } + } + return label; + } + } + } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java new file mode 100644 index 00000000..c5b9870f --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java @@ -0,0 +1,161 @@ +package tools.dscode.workbench.player; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LiveScenarioPlayerTest { + + @Test + void selectionAndPlayheadAreIndependentAndIdsStayStable() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Feature: Demo", + "Scenario: Demo", + "Given first", + "And second" + )); + long firstId = player.lines().get(2).id(); + long secondId = player.lines().get(3).id(); + + player.select(secondId); + LiveScenarioPlayer.Line inserted = player.insertStep("When inserted"); + + assertEquals(secondId, player.selectedId().orElseThrow()); + assertNotEquals(firstId, inserted.id()); + assertNotEquals(secondId, inserted.id()); + assertEquals(inserted.id(), player.nextStep().orElseThrow().id()); + assertEquals(secondId, player.lines().stream() + .filter(line -> line.text().equals("And second")) + .findFirst().orElseThrow().id()); + } + + @Test + void insertionOccursAtPlayheadAndBecomesNextStep() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given first", + "Then second" + )); + long originalFirst = player.nextStep().orElseThrow().id(); + + LiveScenarioPlayer.Line inserted = player.insertStep("When inserted"); + + assertEquals(0, player.playheadIndex()); + assertEquals(inserted.id(), player.nextStep().orElseThrow().id()); + assertEquals(originalFirst, player.lines().get(1).id()); + } + + @Test + void runningWaitsAtEndAndNewStepMakesWorkEligibleAgain() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); + player.play(); + long firstId = player.nextStep().orElseThrow().id(); + + player.markCurrentStepExecuted(firstId); + + assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); + assertTrue(player.nextStep().isEmpty()); + + LiveScenarioPlayer.Line added = player.insertStep("And continue"); + + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + assertEquals(added.id(), player.nextStep().orElseThrow().id()); + } + + @Test + void isolatedExecutionRequestLeavesMainPlayerPaused() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); + player.play(); + + player.pauseForIsolatedExecution(); + + assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + } + + @Test + void failurePausesOnFailedStepWithoutAdvancing() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given first", + "Then second" + )); + player.play(); + long firstId = player.nextStep().orElseThrow().id(); + + player.markCurrentStepFailed(firstId); + + assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + assertEquals(firstId, player.nextStep().orElseThrow().id()); + assertEquals(LiveScenarioPlayer.ExecutionStatus.FAILED, player.nextStep().orElseThrow().executionStatus()); + } + + @Test + void updatingPendingSelectionPreservesIdButExecutedStepCannotBeEdited() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); + long id = player.nextStep().orElseThrow().id(); + player.select(id); + + LiveScenarioPlayer.Line updated = player.updateSelectedStep("Given changed"); + + assertEquals(id, updated.id()); + assertEquals("Given changed", updated.text()); + + player.play(); + player.markCurrentStepExecuted(id); + assertThrows(IllegalStateException.class, () -> player.updateSelectedStep("Given changed again")); + } + + @Test + void nonExecutableLinesAreSkippedWhenAdvancing() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Feature: Demo", + "Given first", + "# comment", + "", + "Then second" + )); + player.play(); + long firstId = player.nextStep().orElseThrow().id(); + player.markCurrentStepExecuted(firstId); + + assertEquals("Then second", player.nextStep().orElseThrow().text()); + } + @Test + void basicPlayerStateTransitionsAreExplicit() { + LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + + assertEquals(LiveScenarioPlayer.State.STOPPED, player.state()); + player.play(); + assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); + player.pause(); + assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + player.stop(); + assertEquals(LiveScenarioPlayer.State.STOPPED, player.state()); + } + + @Test + void firstAndBackMoveOnlyThePlayhead() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given first", + "And second", + "Then third" + )); + long selected = player.lines().get(2).id(); + player.select(selected); + player.play(); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + + player.movePlayheadToPreviousStep(); + assertEquals("And second", player.nextStep().orElseThrow().text()); + assertEquals(selected, player.selectedId().orElseThrow()); + + player.movePlayheadToFirstStep(); + assertEquals("Given first", player.nextStep().orElseThrow().text()); + assertEquals(selected, player.selectedId().orElseThrow()); + } + +} diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index a9016d52..87122098 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -5,7 +5,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Capability | Implementation/search anchors | Consumer/internal coverage | Canonical documentation | |---|---|---|---| | Build, publication, Java compatibility | `build.gradle`; `settings.gradle`; `src/main/aspectj`; `gradle/pickleball-published-variant.gradle`; search `publishing`, `shadowJar`, `pickleballPublishedElements`, `aspectj`, `JavaLanguageVersion` | root tests; `:pickleball-workbench:test`; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / thin Swing UI | `pickleball-workbench`; `gradle/pickleball-published-variant.gradle`; `WorkbenchApplication`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.ui`; Pickleball `WorkbenchWorkerMain`; `DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY` | `:pickleball-workbench:test`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `ControlBridgeClientTest`; `DynamicSuiteBootstrapWorkbenchRootTest`; direct `sync` / `worker-check` / `live-check`; packaged UI smoke | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `gradle/pickleball-published-variant.gradle`; `WorkbenchApplication`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; Pickleball `WorkbenchWorkerMain`; `DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY` | `:pickleball-workbench:test`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; `DynamicSuiteBootstrapWorkbenchRootTest`; direct `sync` / `worker-check` / `live-check`; packaged UI smoke | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core bootstrap `ControlRuntime`; Workbench `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | @@ -43,4 +43,4 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and thin Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI is intentionally execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the session buffer, stable line identities, selection, playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index becaa037..118cfbec 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -443,6 +443,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` @@ -457,6 +458,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerStatus.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index b0e888bc..b6237d54 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -2,7 +2,7 @@ Pickleball Workbench is the separate executable companion for interactive Pickleball execution and investigation. It depends on the normal shaded/woven `tools.dscode:pickleball` artifact; normal Pickleball consumers do not depend on Workbench. -Workbench replaces the former Pickleball Studio application. The final surface is deliberately execution-oriented: project synchronization, a persistent consumer worker, live runtime control, Mapping, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and a thin Swing UI. +Workbench replaces the former Pickleball Studio application. The supported architecture is deliberately execution-oriented: project synchronization, a persistent consumer worker, live runtime control, Mapping, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and a Swing UI over the same service seam. ## Architecture @@ -14,7 +14,7 @@ pickleball-workbench -> pickleball Pickleball owns scenario execution semantics, Cucumber integration, DynamicControl/Gherkin execution, Mapping, browser/service behavior, the consumer-side Control Bridge, semantic hooks/breakpoints, Step Overrides, and woven Cucumber/AspectJ behavior. -Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the controller-side bridge client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, and the thin Swing UI. +Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the controller-side bridge client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, the headless live-scenario presentation model, and the Swing adapter. MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. @@ -43,10 +43,10 @@ The executable is: pickleball-workbench/build/libs/pickleball-workbench-.jar ``` -Synchronize a consumer project before starting a worker: +Synchronize a consumer project before starting a worker manually: ```powershell -$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-2.1.8.jar" +$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" java -jar $workbenchJar sync ".\maven-consumer-project" ``` @@ -54,20 +54,107 @@ Synchronization uses the selected project wrapper to establish compiled output a ## Swing UI -Start the thin Workbench UI for one consumer project: +Start Workbench for one consumer project: ```powershell java -jar $workbenchJar ui ".\maven-consumer-project" ``` -The Swing UI is a presentation adapter over the same `WorkbenchServices` / `WorkbenchController` seam used by MCP. It does not own a second worker manager, bridge client, Mapping implementation, or Pickleball execution model. +The Swing UI is a presentation adapter over the same `WorkbenchServices` / `WorkbenchController` seam used by MCP. It does not own a second worker manager, bridge client, Mapping implementation, Gherkin execution engine, or Pickleball runtime model. -The UI provides: +### Player-style layout + +The primary workspace is now arranged as an interactive scenario player: + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ Project / readiness ⏮ ◀ ▶ ⏸ ■ Player status │ +├───────────────────────────────┬─────────────────────────────────────┤ +│ LIVE SCENARIO EDITOR │ Mapping | Terminal | Diagnostic Log │ +│ │ │ +│ Feature: ... │ selected right-side workspace │ +│ Scenario: ... │ │ +│ ▶ next playhead step │ │ +│ selected/other line │ │ +├───────────────────────────────┤ │ +│ Step Editor / Command ▶ │ │ +│ [ live command text ] │ │ +└───────────────────────────────┴─────────────────────────────────────┘ +│ Workbench/session activity │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +The left side contains only the Live Scenario Editor and compact Step Editor / Command. The right side has one tabbed workspace for Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls are available from the **Session** menu. Existing investigation tools are available from **Tools > Advanced Controls** so the underlying capabilities are preserved without dominating the normal workflow. + +### Live scenario buffer and player state + +`LiveScenarioPlayer` is a headless Workbench-side presentation model. It owns only: + +- stable line identities independent of display line number; +- the live session buffer; +- selected line; +- playhead insertion point; +- pending/executed/failed presentation status; +- player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. + +Selection and playhead are independent. Selecting another line does not move the playhead. New Step Editor commands are inserted at the playhead insertion point, not at the text caret or selected line. + +The initial buffer is an interactive session buffer; it is not automatically written back to consumer `.feature` files. + +Step Editor gestures are intentionally explicit: + +```text +Enter insert a new command at the playhead +Ctrl+Enter update the selected pending executable step +▶ execute the Step Editor text in isolation through the existing live service +``` + +Executed or failed buffer steps cannot be edited in place in this phase because the UI must not imply that browser, service, or other external side effects were undone. + +The **First** and **Step Back** controls are navigation-only in this phase. They do not claim to rewind Pickleball runtime state or undo external side effects. + +### Phase 1 execution boundary + +The current player-style increment establishes the new layout and headless player/buffer state without inventing new Pickleball runtime semantics. + +The main Play/Pause/Stop controls currently update the buffer/player state model only. Automatic buffered execution, wait-at-end execution, and add-and-continue behavior are the next implementation phase. That loop must be wired through an explicit Pickleball-owned Gherkin/runtime contract; Swing must not strip `Given`/`When`/`Then`, create a second step matcher, or otherwise guess how displayed Gherkin maps to detached step execution. + +The small Step Editor Play button continues to use the existing `WorkbenchServices.executeStep` contract unchanged. It pauses the main player state before isolated execution and does not automatically resume it afterward. + +### Mapping tab + +There is no GUI-defined `Current Scope` concept. + +The target Mapping design is a single NodeMap selector populated from the real `ParsingMap` associated with the selected step, with common NodeMaps remaining available across the live scenario. The GUI must not hard-code names or recreate inheritance rules. + +The Phase 1 UI therefore leaves the NodeMap selector unavailable until the required Pickleball-side ParsingMap inspection contract exists. Existing Mapping get/put/resolve controls remain available as compatibility controls. The Swing Mapping put control continues to send entered values as text; MCP continues to support arbitrary JSON-compatible Mapping values through the shared service method. + +Structural NodeMap browsing/mutation is a later phase and must be implemented against real worker-side Pickleball state. + +### Terminal and Diagnostic Log Explorer + +The Terminal tab currently displays Workbench UI activity only. Worker log streaming, level filtering, search, and auto-scroll belong to the Terminal phase and must use the appropriate worker/Workbench logging source without violating the MCP stdout contract. + +The Diagnostic Log Explorer tab is intentionally a placeholder in Phase 1. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: + +1. `run-catalog.json` +2. selected `run-index.json` / `clusters.json` +3. scenario `summary.json` +4. relevant `events.jsonl` +5. existing comparison/fingerprint metadata +6. PNG only when visual content must be inspected +7. raw trace only when structured evidence is insufficient + +The Swing UI must not create a competing diagnostic storage format or fake retained-run data. + +### Existing advanced capabilities + +The redesign preserves the underlying existing capabilities: - selected project display and synchronization/status refresh; - synchronize, start worker, restart fresh worker without rebuilding, and stop worker; - worker PID/runtime/scenario/pause status; -- live raw Gherkin step input with optional argument text and result/status output; +- live raw Gherkin execution; - Mapping get, put, and resolve; - incremental semantic-event display with timestamp, hook, step/phrase, and signature detail; - Step Override list, worker-side compile/replace, remove, and clear; @@ -76,13 +163,11 @@ The UI provides: - semantic breakpoint list, add, remove, and clear with hook/filter/one-shot/finite-lease controls; - clean Workbench shutdown when the window closes. -The Mapping put control stores the entered Swing value as text. MCP continues to support arbitrary JSON-compatible Mapping values through the same service method. - -The Step Override editor sends its source template unchanged to the worker. The source must contain `{{CLASS_NAME}}`; generated class naming, compilation, classloading, rule registration, matching, captures, replacement, and cleanup remain worker-side Pickleball responsibilities. Browser page, screenshot, service-call, event, and breakpoint controls expose the existing bridge contracts rather than reimplementing them in Swing. +The Step Override editor sends its source template unchanged to the worker. The source must contain `{{CLASS_NAME}}`; generated class naming, compilation, classloading, rule registration, matching, captures, replacement, and cleanup remain worker-side Pickleball responsibilities. Browser page, screenshot, service-call, event, and breakpoint controls expose existing bridge contracts rather than reimplementing them in Swing. Synchronization, worker actions, live bridge calls, Mapping operations, event refresh, Step Override actions, browser/screenshot evidence, service calls, and breakpoint actions run off the Swing Event Dispatch Thread. Live controls are enabled only while the Workbench-owned worker is running and paused. -The UI is intentionally not a project IDE, file editor, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. +The UI is intentionally not a project IDE, general feature-file editor, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. ## MCP stdio @@ -196,27 +281,33 @@ io.modelcontextprotocol.sdk:mcp-core:2.0.0 io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0 ``` -## Manual UI acceptance +## Manual UI acceptance for the Phase 1 player foundation ```powershell -$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-2.1.8.jar" +$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" java -jar $workbenchJar ui ".\maven-consumer-project" ``` -Use the UI-owned worker for this smoke test; do not run `worker-check` or `live-check` concurrently with the UI. +Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. -1. **Status:** click **Start Worker** and verify `Paused: true` with a PID/runtime/scenario. -2. **Live Gherkin:** execute `CONTROL API TEST STEP` and verify `Status: SUCCESS`. -3. **Mapping:** put/get/resolve `OVERRIDE / workbenchLiveValue = first` and verify `first` is returned. -4. **Step Overrides:** leave the prefilled id/regex/source, click **Compile / Replace**, and verify `Status: SUCCESS` plus one installed override. In **Live Gherkin**, execute `WORKBENCH UI OVERRIDE alpha`; then in **Mapping**, get `OVERRIDE / workbenchStepOverrideValue` and verify `ui-alpha`. Return to **Step Overrides**, click **Remove ID**, and verify the installed list is empty. -5. **Evidence / Service Call:** execute `%health-full-url` and verify `Status: SUCCESS` and `HTTP status: 200`. -6. **Evidence / Browser:** in **Live Gherkin**, execute `navigate to: URL.home`; then click **Read Page** and verify the URL/title/page source contains the Pickleball test page. Click **Capture Screenshot** and verify a PNG image is displayed. -7. **Breakpoints:** with the prefilled `BEFORE_STEP`, `CONTROL API TEST STEP`, one-shot, and `120` second lease, click **Add** and verify one breakpoint is listed. Copy its generated id into **Breakpoint ID (for remove)**, click **Remove ID**, and verify the list is empty. -8. **Recent Events:** verify semantic events are present and include sequence, timestamp, hook, and step/phrase/signature detail. -9. **Lifecycle:** note the PID, click **Restart Worker**, verify a different PID with `Paused: true`, execute one live step successfully, then click **Stop Worker** and verify `Not running (exit=0)`. +1. Verify the top-level layout has the Live Scenario Editor and compact Step Editor on the left, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. +2. Insert multiple commands with Enter and verify each is inserted at the visible playhead while selection can remain on another line. +3. Select a pending command, change its text, press Ctrl+Enter, and verify its displayed line updates without changing its stable position semantics. +4. Use First and Step Back and verify the playhead indicator moves independently from the selection. Treat these as navigation-only; no runtime rewind is claimed. +5. Use Play/Pause/Stop and verify player presentation states, including `Waiting for next step...` when Play has no next buffered command. Do not treat this as Phase 2 automatic runtime execution. +6. Open **Session**, synchronize/start a worker, select or enter a valid existing live raw Gherkin command, click the small Step Editor Play button, and verify it delegates isolated execution and leaves the main player paused. +7. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Existing get/put/resolve controls remain usable with a paused worker. +8. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +9. Verify blocking runtime actions leave the Swing UI responsive. ## Regression +For the player state model and Swing/controller behavior: + +```powershell +.\gradlew.bat :pickleball-workbench:test +``` + For shared controller/MCP behavior: ```powershell @@ -226,7 +317,7 @@ For shared controller/MCP behavior: For persistent worker/live behavior: ```powershell -$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-2.1.8.jar" +$workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" java -jar $workbenchJar sync ".\maven-consumer-project" java -jar $workbenchJar worker-check ".\maven-consumer-project" From 1df73f2f4651f9baf21767b00982d3d56fc681f1 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 20 Aug 2026 08:48:03 -0700 Subject: [PATCH 02/15] updating workbench --- .../pickleball/ControlBridgeTestSteps.java | 35 +++++++++++++++---- .../StepOverrideBridgeTestSteps.java | 12 +++---- .../resources/features/control-bridge.feature | 2 +- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/ControlBridgeTestSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/ControlBridgeTestSteps.java index dddfff2b..f3dfe993 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/ControlBridgeTestSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/ControlBridgeTestSteps.java @@ -1,10 +1,10 @@ package com.example.pickleball; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.cucumber.java.After; import io.cucumber.java.en.Given; -import tools.dscode.control.bridge.*; +import tools.dscode.control.bridge.ControlBridgeBootstrap; +import tools.dscode.control.protocol.*; import tools.dscode.coredefinitions.BrowserSteps; import java.io.IOException; @@ -210,6 +210,28 @@ public void controlBridgeIpcSyncPoint() { @Given("^VERIFY CONTROL BRIDGE IPC TEST$") public void verifyControlBridgeIpcTest() throws Exception { ClientOutcome outcome = client.get(25, TimeUnit.SECONDS); + assertEquals( + ControlProtocol.CURRENT_VERSION, + descriptor.protocolVersion(), + "descriptor protocol version" + ); + assertEquals( + ControlProtocol.MINIMUM_COMPATIBLE_VERSION, + descriptor.minimumCompatibleProtocolVersion(), + "descriptor minimum protocol version" + ); + assertEquals(ProcessHandle.current().pid(), descriptor.pid(), "consumer runtime PID"); + assertEquals("127.0.0.1", descriptor.host(), "loopback host"); + assertTrue( + descriptor.runtimeCodeSource() != null + && !descriptor.runtimeCodeSource().isBlank() + && !"unknown".equals(descriptor.runtimeCodeSource()), + "consumer runtime code source" + ); + assertTrue( + descriptor.capabilities().containsAll(ControlProtocol.WORKER_CAPABILITIES), + "descriptor capabilities" + ); assertEquals(401, outcome.unauthorizedStatus(), "wrong/missing token status"); assertEquals(getCurrentScenarioState().id.toString(), outcome.scenario().scenarioId(), "targeted scenario id"); assertEquals("UNAVAILABLE", outcome.wrongTarget().status(), "wrong scenario target"); @@ -382,10 +404,11 @@ private static void assertIncreasing(List events) { } } - private static void assertMaterializedMappingValue(JsonNode value, String expected, String label) { - assertTrue(value != null && value.isArray(), label + " should be a materialized collection"); - assertTrue(value.size() > 0, label + " should not be empty"); - assertEquals(expected, value.get(value.size() - 1).asText(), label); + private static void assertMaterializedMappingValue(Object value, String expected, String label) { + assertTrue(value instanceof List, label + " should be a materialized collection"); + List values = (List) value; + assertTrue(!values.isEmpty(), label + " should not be empty"); + assertEquals(expected, values.getLast(), label); } private static void assertMappingValue(ControlBridgeValueResult result, Object expected, String label) { diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/StepOverrideBridgeTestSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/StepOverrideBridgeTestSteps.java index 5d70cb6c..c67e2d87 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/StepOverrideBridgeTestSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/StepOverrideBridgeTestSteps.java @@ -4,12 +4,12 @@ import io.cucumber.java.After; import io.cucumber.java.en.Given; import tools.dscode.control.bridge.ControlBridgeBootstrap; -import tools.dscode.control.bridge.ControlBridgeCallResult; -import tools.dscode.control.bridge.ControlBridgeDescriptor; -import tools.dscode.control.bridge.ControlBridgeScenarioStatus; -import tools.dscode.control.bridge.ControlBridgeStepOverride; -import tools.dscode.control.bridge.ControlBridgeStepOverrideResult; -import tools.dscode.control.bridge.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ControlBridgeDescriptor; +import tools.dscode.control.protocol.ControlBridgeScenarioStatus; +import tools.dscode.control.protocol.ControlBridgeStepOverride; +import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; +import tools.dscode.control.protocol.ControlBridgeValueResult; import java.io.IOException; import java.net.URI; diff --git a/maven-consumer-project/src/test/resources/features/control-bridge.feature b/maven-consumer-project/src/test/resources/features/control-bridge.feature index 3a45e2ec..14f32440 100644 --- a/maven-consumer-project/src/test/resources/features/control-bridge.feature +++ b/maven-consumer-project/src/test/resources/features/control-bridge.feature @@ -1,5 +1,5 @@ @all @smoke @control-bridge @phase3h @phase4 -Feature: Pickleball Studio control bridge +Feature: Pickleball Workbench consumer-worker control bridge Scenario: Paused runtime supports retry-friendly investigation and control Given BEGIN CONTROL BRIDGE IPC TEST From 85fd986a95817a63472e9de2a41d5f50edc379ad Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Fri, 21 Aug 2026 07:43:00 -0700 Subject: [PATCH 03/15] bundled workbench --- AGENTS.md | 41 ++- BUNDLE-MANIFEST.txt | 23 +- README-APPLY.md | 97 +++--- README.md | 3 +- REVIEW.md | 10 + build.gradle | 92 +++++- docs/README.md | 2 +- docs/agent/README.md | 13 + docs/agent/change-checklist.md | 7 +- docs/agent/feature-map.md | 20 +- docs/agent/repository-index.md | 37 ++- docs/consumer-project.md | 19 ++ docs/dynamic-control-api.md | 8 +- docs/getting-started.md | 15 +- docs/pickleball-workbench.md | 98 +++++- docs/step-overrides.md | 2 +- gradle/pickleball-published-variant.gradle | 87 +---- pickleball-control-api/build.gradle | 2 + .../bridge/ControlBridgeBootstrap.java | 11 +- .../bridge/ControlBridgeBreakpoint.java | 2 + .../bridge/ControlBridgeBrowserPage.java | 3 +- .../ControlBridgeBrowserPageResult.java | 3 +- .../bridge/ControlBridgeCoordinator.java | 105 +++++- .../bridge/ControlBridgeEventRecorder.java | 2 + .../bridge/ControlBridgeMappingSnapshot.java | 2 +- .../ControlBridgeMappingSnapshotResult.java | 3 +- .../control/bridge/ControlBridgeRuntime.java | 93 +++--- .../bridge/ControlBridgeValueResult.java | 3 +- pickleball-control-protocol/build.gradle | 50 +++ .../ControlBridgeBoundedJsonEvidence.java | 12 + .../protocol/ControlBridgeBreakpoint.java | 16 + .../protocol/ControlBridgeBrowserPage.java | 19 ++ .../ControlBridgeBrowserPageResult.java | 10 + .../ControlBridgeBrowserScreenshot.java | 9 + .../ControlBridgeBrowserScreenshotResult.java | 10 + .../protocol/ControlBridgeCallResult.java | 11 + .../protocol/ControlBridgeDescriptor.java | 22 ++ .../ControlBridgeElementEvidence.java | 29 ++ .../ControlBridgeElementInspection.java | 18 ++ .../ControlBridgeElementInspectionResult.java | 9 + .../control/protocol/ControlBridgeError.java | 9 + .../control/protocol/ControlBridgeEvent.java | 15 + .../protocol/ControlBridgeEventPage.java | 17 + .../control/protocol/ControlBridgeJson.java | 44 +++ .../ControlBridgeMappingSnapshot.java | 22 ++ .../ControlBridgeMappingSnapshotResult.java | 10 + .../protocol/ControlBridgeRequests.java | 53 +++ .../protocol/ControlBridgeResponses.java | 10 + .../protocol/ControlBridgeScenarioStatus.java | 15 + .../ControlBridgeServiceCallEvidence.java | 11 + .../ControlBridgeServiceCallResult.java | 9 + .../control/protocol/ControlBridgeStatus.java | 25 ++ .../protocol/ControlBridgeStepOverride.java | 9 + .../ControlBridgeStepOverrideResult.java | 9 + .../control/protocol/ControlBridgeValue.java | 13 + .../protocol/ControlBridgeValueResult.java | 10 + .../control/protocol/ControlProtocol.java | 33 ++ pickleball-workbench/AGENTS.md | 26 +- pickleball-workbench/build.gradle | 234 ++++++++------ .../workbench/WorkbenchApplication.java | 18 +- .../dscode/workbench/WorkbenchController.java | 2 +- .../workbench/WorkbenchRuntimeBoundary.java | 64 ++++ .../dscode/workbench/WorkbenchServices.java | 24 +- .../workbench/bridge/ControlBridgeClient.java | 91 +++--- .../workbench/mcp/WorkbenchMcpTools.java | 2 +- .../workbench/ui/WorkbenchUiController.java | 42 +-- .../worker/WorkbenchLiveSession.java | 2 +- .../worker/WorkbenchWorkerManager.java | 148 ++++++++- .../WorkbenchRuntimeBoundaryTest.java | 17 + .../bridge/ControlBridgeClientTest.java | 303 ++++++++++++++---- .../ui/WorkbenchUiControllerTest.java | 37 ++- .../worker/WorkbenchWorkerManagerTest.java | 114 ++++++- scripts/agent_validate.ps1 | 35 +- scripts/agent_validate.sh | 19 +- scripts/refresh_agent_index.py | 5 + scripts/verify_agent_contract.py | 102 ++++++ settings.gradle | 2 +- .../launcher/PickleballWorkbenchLauncher.java | 158 +++++++++ .../testengine/DynamicSuiteBootstrap.java | 4 +- .../pickleball/guidance/docs/README.md | 2 +- .../pickleball/guidance/docs/agent/README.md | 13 + .../guidance/docs/agent/change-checklist.md | 7 +- .../guidance/docs/agent/feature-map.md | 20 +- .../guidance/docs/agent/repository-index.md | 37 ++- .../guidance/docs/consumer-project.md | 19 ++ .../guidance/docs/dynamic-control-api.md | 8 +- .../guidance/docs/getting-started.md | 15 +- .../guidance/docs/pickleball-workbench.md | 98 +++++- .../guidance/docs/step-overrides.md | 2 +- .../PickleballWorkbenchLauncherTest.java | 62 ++++ 90 files changed, 2512 insertions(+), 552 deletions(-) create mode 100644 pickleball-control-protocol/build.gradle create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBoundedJsonEvidence.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBreakpoint.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPage.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPageResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshot.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshotResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeCallResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeDescriptor.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementEvidence.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspection.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspectionResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeError.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEvent.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEventPage.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeJson.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshot.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshotResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeRequests.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeResponses.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeScenarioStatus.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallEvidence.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStatus.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverride.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverrideResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java create mode 100644 src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java create mode 100644 src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java diff --git a/AGENTS.md b/AGENTS.md index 65f25d72..28b51fff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,11 +69,13 @@ Diagnostic lineage is metadata, not part of the execution RunVars. Supply `pkb_i Use all relevant evidence rather than trusting one file in isolation: - Current implementation under `src/main/java` and `src/main/aspectj` +- Dependency-neutral controller/worker wire contracts under `pickleball-control-protocol`; this module must remain JDK-only - Dynamic-control companion source module under `pickleball-control-api`; its classes are bundled into the main `tools.dscode:pickleball` artifact and are not a separate consumer dependency +- Controller-only Workbench source under `pickleball-workbench`; it may depend on `pickleball-control-protocol` but never on Pickleball core or `pickleball-control-api` - Consumer-hosted internal Java checks under `maven-consumer-project/src/test/java` - Executable consumer examples under `maven-consumer-project/src/test` - `README.md` and the guides under `docs` -- Build and dependency configuration in `build.gradle`, `settings.gradle`, `pickleball-control-api/build.gradle`, and `maven-consumer-project/pom.xml` +- Build and dependency configuration in `build.gradle`, `settings.gradle`, `pickleball-control-protocol/build.gradle`, `pickleball-control-api/build.gradle`, `pickleball-workbench/build.gradle`, and `maven-consumer-project/pom.xml` - `docs/agent/feature-map.md` for navigation, not as a replacement for source inspection When implementation, tests, examples, and documentation disagree: @@ -89,7 +91,9 @@ When implementation, tests, examples, and documentation disagree: - `src/main/java` — framework implementation and Cucumber integrations - `src/main/aspectj` — AspectJ integrations and weaving behavior - `src/main/resources` — framework resources +- `pickleball-control-protocol` — JDK-only versioned wire records, capability/version constants, request envelopes, and response envelopes shared by core/worker and Workbench; no runtime behavior - `pickleball-control-api` — internal companion source module for retry-friendly detached execution, Gherkin utilities, ParsingMap/NodeMap inspection and emulation, and dynamic controller tooling; bundled into the main Pickleball artifact rather than published separately +- `pickleball-workbench` — controller-only GUI/MCP/synchronization/process client; its executable must contain no Pickleball, Cucumber, Selenium, REST-assured, worker, or behavioral control-API implementation - `src/test` — reserved for tests that must run inside the framework build - `docs` — detailed user-facing documentation - `maven-consumer-project` — executable Maven consumer example @@ -167,6 +171,16 @@ Internal Java checks should normally live in `maven-consumer-project` and be exe Tests must cover the requested behavior and meaningful compatibility or edge cases. Do not weaken or delete assertions merely to make a change pass. +For Workbench, control-protocol, worker bridge, launcher, or nested-payload changes, never use `@all` as migration validation. Run only the smallest affected tags (currently `@control-bridge` and/or `@step-override-bridge`) and set `-Dpkb_runvars.pkb_parallel=80` when the focused environment can safely benefit. Preserve this rule in future Workbench plans and handovers. + +### Workbench controller-isolation invariant + +Pickleball Workbench is the control plane, not a second Pickleball runtime. The only shared Java boundary is `pickleball-control-protocol`. Workbench must not compile against, resolve, shade, load, or execute the root Pickleball project, `tools.dscode:pickleball`, `pickleball-control-api`, the consumer's classes, or runtime libraries such as Cucumber, Selenium, and REST-assured. Never “fix” a Workbench compilation problem by restoring `implementation project(':')`, `pickleballPublishedElements`, a Pickleball Maven dependency, or core shading. + +Only the separate consumer worker JVM executes Pickleball. `WorkbenchWorkerManager` must launch `ControlProtocol.WORKER_MAIN_CLASS` by name on the consumer build's captured test-runtime classpath, and must verify the worker PID, Pickleball code source, synchronized version, and absence of the Workbench controller artifact. Commands and state cross the local authenticated versioned protocol, not direct Java calls. + +The published Pickleball JAR embeds the completed controller-only Workbench JAR as exactly one opaque payload at `META-INF/pickleball/workbench/pickleball-workbench.jar`. It must not flatten Workbench/MCP classes into the outer runtime, and the nested Workbench must not contain core. The ownership mnemonic is: **Pickleball may contain Workbench; Workbench must not contain Pickleball.** + ## Build and validation Use Java 21. @@ -177,13 +191,34 @@ Framework validation: ./gradlew test ``` +Strict controller/artifact validation: + +```shell +./gradlew verifyStrictControllerIsolation :pickleball-workbench:test +``` + Windows: ```powershell .\gradlew.bat test ``` -For consumer-visible changes, publish the current framework artifact locally and run the Maven consumer: +Workbench/control-bridge scenario validation must stay focused: + +```shell +./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_parallel=80 -Dpkb_runvars.pkb_tags=@control-bridge +./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_parallel=80 -Dpkb_runvars.pkb_tags=@step-override-bridge +``` + +Run the two commands sequentially because both scenarios intentionally exercise the process-global bridge bootstrap. + +Or use the focused turnkey validator: + +```shell +scripts/agent_validate.sh --workbench +``` + +For broad consumer-visible changes outside the Workbench/controller-isolation surface, publish the current framework artifact locally and run the Maven consumer. The focused Workbench rule above takes precedence for that surface: ```shell ./gradlew test publishToMavenLocal @@ -227,6 +262,7 @@ If a required validation cannot run, state exactly what was not run and why. Nev - Do not edit generated build output. - Preserve backward compatibility unless the user explicitly approves a breaking change. - Dynamic-control additions must remain opt-in: no handler/API call means normal scenario traversal, ParsingMap construction/order, NodeMap references, resolution, and writes retain their pre-control behavior. +- Workbench changes must preserve strict physical, dependency, process, and classpath isolation; separate JVMs alone are not sufficient. - Follow existing code style and patterns before introducing new abstractions. - Do not replace executable examples with prose. - Never store secrets, credentials, machine-specific paths, or private data in agent instruction files. @@ -281,6 +317,7 @@ A functionality change is complete only when: - Applicable compatibility has been preserved or a breaking change is clearly identified. - Relevant consumer-hosted internal Java checks exist and pass. - Relevant consumer scenarios exist and pass when applicable. +- Workbench/core changes retain the neutral protocol boundary, controller-only artifact scan, opaque nested payload, and consumer-owned worker runtime. - Documentation matches the resulting behavior. - The feature map remains accurate. - The generated repository index is current. diff --git a/BUNDLE-MANIFEST.txt b/BUNDLE-MANIFEST.txt index 632bfe58..8c5bc3e5 100644 --- a/BUNDLE-MANIFEST.txt +++ b/BUNDLE-MANIFEST.txt @@ -1,10 +1,23 @@ -Pickleball 2.1.3 failure-cluster metadata follow-up +Pickleball 2.1.9 strict Workbench controller isolation +Target: branch 2.1.9, commit 9c255431f23a4fa48a3615b387610b3367f476af + +APPLY: +Copy every archive entry over the repository root, preserving folder structure. +No file deletion is required. + +NEW: +pickleball-control-protocol/** +src/main/java/tools/dscode/launcher/** +src/test/java/tools/dscode/launcher/** +pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java +pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java REPLACE: -src/main/aspectj/tools/dscode/common/reporting/diagnostic/Diagnostic213CompletionAspect.aj -maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/Diagnostic213CompletionChecks.java -docs/diagnostic-reporting.md -docs/agent/feature-map.md +Build graph and isolation verification files +Worker-side bridge and controller-side protocol client files +Focused Workbench/control-bridge tests +Human and AI documentation/infrastructure +Generated repository index and packaged consumer-guidance mirrors REFERENCE: README-APPLY.md diff --git a/README-APPLY.md b/README-APPLY.md index 8c0a01a5..90097ce0 100644 --- a/README-APPLY.md +++ b/README-APPLY.md @@ -1,65 +1,74 @@ -# Pickleball 2.1.3 failure-cluster metadata follow-up +# Pickleball 2.1.9 strict Workbench controller isolation -This bundle is intended to be copied over the repository after the 2.1.3 site-aware failure-signature fix has already been applied. +This drop-in targets branch `2.1.9` at commit `9c255431f23a4fa48a3615b387610b3367f476af`. -It keeps the verified V2 clustering behavior and adds the sparse metadata an AI/developer needs to understand why two failures are in different clusters without opening dense event logs. +Copy the archive contents over the project root, preserving paths. Every included project file is a complete replacement or new file. The bundle intentionally requires no file deletion; the obsolete `gradle/pickleball-published-variant.gradle` is replaced by a migration tombstone so a drag-and-drop overlay cannot retain its old build logic. -## Replacement files +## Result -- `src/main/aspectj/tools/dscode/common/reporting/diagnostic/Diagnostic213CompletionAspect.aj` -- `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/Diagnostic213CompletionChecks.java` -- `docs/diagnostic-reporting.md` -- `docs/agent/feature-map.md` +- `pickleball-control-protocol` is a JDK-only module containing protocol versions, capabilities, transport constants, request/response envelopes, and immutable wire records. +- `pickleball-workbench` depends only on that protocol plus controller libraries. It no longer resolves, imports, shades, loads, or executes Pickleball core or the behavioral control API. +- Worker-side bridge server/coordinator/bootstrap and all execution behavior remain in Pickleball and run from the consumer project's captured test runtime in a separate JVM. +- Protocol connection checks require compatible versions/capabilities, distinct controller/worker PIDs, consumer-classpath runtime origin, synchronized Pickleball version, and no Workbench controller on the worker classpath. +- The root Pickleball JAR embeds one byte-identical controller-only Workbench JAR as opaque bytes at `META-INF/pickleball/workbench/pickleball-workbench.jar`; Workbench/MCP entries are not flattened into the outer runtime. +- `PickleballWorkbenchLauncher` extracts the embedded payload atomically by SHA-256 beneath `.pickleball/workbench/controller/` and always launches it with `java -jar` in a separate JVM. +- Artifact, dependency, POM, nested-JAR/service, controller-classpath, worker-origin, launcher, protocol-client, and focused consumer checks enforce the boundary. +- Canonical human documentation, repository agent guidance, review rules, generated indexes, packaged consumer guidance, and validation scripts describe the same architecture. -All project files in this bundle are full replacements, not patches. +The permanent ownership rule is: **Pickleball may contain Workbench; Workbench must not contain Pickleball.** -## Resulting failure metadata +## Validation performed for this handoff -For a structured step failure, the sparse scenario summary and run index now retain: +Completed in the bundle workspace: -```json -{ - "failureSignature": "...", - "failureSignatureVersion": 2, - "failureSiteKey": "...", - "failureSite": { - "feature": "features/diagnostic-reporting-validation.feature", - "stepLine": 60, - "definition": "tools.dscode.coredefinitions.DynamicSteps#executeDynamicStep" - } -} -``` - -`clusters.json` carries the same metadata. `DiagnosticRunComparator` keeps it in compact scenario transitions. `DiagnosticIndexRebuilder` preserves it when rebuilding clusters from surviving scenario summaries. +- `python3 scripts/verify_agent_contract.py`; +- `python3 scripts/refresh_agent_index.py --check`; +- `python3 scripts/sync_consumer_guidance.py --check`; +- `git diff --check` and `bash -n scripts/agent_validate.sh`; +- JDK compiler probes for the dependency-free protocol, launcher, controller runtime guard, protocol client, worker lifecycle/live-session seam, shared controller service, UI controller, and focused client/launcher tests (using narrow temporary type stubs where third-party libraries were unavailable); +- a launcher harness covering content-addressed extraction, corrupted-cache repair, and the separate `java -jar` command; and +- a controller-boundary harness proving the isolated classpath cannot see Pickleball core. -If no structured step site exists, the previous class/message-only signature is preserved and `failureSignatureVersion` is `1`; no fake site metadata is created. +The workspace provided Java 17 only and could not resolve the Gradle 9.7 distribution or Maven dependencies through its restricted network. Therefore the Java 21 Gradle build, publication, executable/nested-JAR inspection tasks, Workbench unit suite, and Cucumber scenarios were **not executed here and are not claimed as passing**. Run the focused Java 21 commands below after applying the bundle. -## Validate +## Focused validation -From the Pickleball repository root: +Use Java 21 and an environment that can resolve the existing Gradle/Maven dependencies: -```powershell -.\gradlew.bat test publishToMavenLocal -python scripts/refresh_agent_index.py --check -python scripts/verify_agent_contract.py +```bash +scripts/agent_validate.sh --workbench ``` -From `maven-consumer-project`, run the consumer-hosted internal checks: - -```powershell -mvn test -Dpkb_tags="@diagnostic-single" +Equivalent explicit commands: + +```bash +python3 scripts/verify_agent_contract.py +python3 scripts/refresh_agent_index.py --check +python3 scripts/sync_consumer_guidance.py --check +./gradlew verifyStrictControllerIsolation :pickleball-workbench:test publishToMavenLocal +./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test \ + -Dpkb_runvars.pkb_browser=CHROME_HEADLESS \ + -Dpkb_runvars.pkb_parallel=80 \ + -Dpkb_runvars.pkb_tags=@control-bridge +./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test \ + -Dpkb_runvars.pkb_browser=CHROME_HEADLESS \ + -Dpkb_runvars.pkb_parallel=80 \ + -Dpkb_runvars.pkb_tags=@step-override-bridge ``` -Then rerun the focused cluster scenario set: +Run the two Maven commands sequentially because both scenarios exercise the process-global bridge bootstrap. -```powershell -mvn test -Dpkb_tags="@diagnostic-cluster-validation" -Dpkb_reportingmode="diagnostic" -Dpkb_reportretention="all" -Dpkb_browser="CHROME_HEADLESS" -Dpkb_investigation_id="diag-213-cluster-metadata" -Dpkb_run_purpose="failure-signature-metadata" -``` +Do not use `@all` for this migration. -The cluster-validation Maven command is expected to fail because both scenarios intentionally fail. +## Consumer launch -For each failed scenario, verify `summary.json` and the corresponding `run-index.json` scenario entry contain `failureSignatureVersion`, `failureSiteKey`, and `failureSite`. Verify each `clusters.json` entry contains the same metadata. +A Maven consumer can launch the matching embedded controller without locating a cache entry or declaring a second version: -The two intentional failures should still have different `failureSignature` and `failureSiteKey` values. +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` -No IntelliJ Cucumber rerun is required for this follow-up; the internal checks validate canonical Maven/IntelliJ feature-source handling and the sparse metadata/rebuild/comparison behavior. +See `docs/pickleball-workbench.md` for architecture, commands, lifecycle, MCP stdout rules, and verification details. diff --git a/README.md b/README.md index 09b7ccd1..d67a60c9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Pickleball also adds: - reusable component scenarios and REST or SOAP service-call scenarios; - composable execution profiles through `pkb_profile`, controlled execution input through `pkb_runvars`, and deterministic final RunVar output through `pkb_run_profile`; - configurable `pkb_configpath` loading with recommended `` references and legacy `` compatibility; +- a dependency-matched Workbench controller embedded as an opaque payload, with all Pickleball execution isolated in a separate consumer worker JVM; - optional `pkb_reportingmode=diagnostic` evidence capture with lightweight run/scenario indexes, losslessly compressed deep trace evidence, Git/source provenance, structured step/capability metadata, binary screenshots, compact visual fingerprints, failure clustering, cross-run comparison, and configurable evidence retention; and - a small consumer setup consisting primarily of the Pickleball dependency and one test runner. @@ -24,4 +25,4 @@ Pickleball remains compatible with standard Cucumber features such as tags, Scen The working [`maven-consumer-project`](docs/consumer-project.md) starts a loopback test server during the run. Its scenarios exercise both Selenium against a local HTML test site and service calls against local REST and SOAP endpoints. -[Read the Pickleball documentation](docs/README.md) · [Consumer project guide](docs/consumer-project.md) · [Execution configuration](docs/configuration.md) · [AI run configuration](docs/ai-run-configuration.md) · [Diagnostic lineage metadata](docs/diagnostic-lineage-metadata.md) · [Diagnostic reporting](docs/diagnostic-reporting.md) +[Read the Pickleball documentation](docs/README.md) · [Pickleball Workbench](docs/pickleball-workbench.md) · [Consumer project guide](docs/consumer-project.md) · [Execution configuration](docs/configuration.md) · [AI run configuration](docs/ai-run-configuration.md) · [Diagnostic lineage metadata](docs/diagnostic-lineage-metadata.md) · [Diagnostic reporting](docs/diagnostic-reporting.md) diff --git a/REVIEW.md b/REVIEW.md index 8224734d..c212b99a 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -13,6 +13,16 @@ Review changes against the following repository requirements. - Flag consumer-visible behavior changes without a Maven consumer scenario when one is practical. - Check supporting service definitions, test data, configuration, local endpoints, and pages. - Flag weakened or removed assertions that merely hide failures. +- For Workbench/protocol changes, require focused `@control-bridge` and/or `@step-override-bridge` coverage with `pkb_parallel=80` where practical; flag `@all` as the migration-validation tag. + +## Workbench controller isolation + +- Reject any Workbench compile/runtime dependency on root Pickleball, `tools.dscode:pickleball`, a published-equivalent variant, behavioral `pickleball-control-api`, Cucumber, Selenium, or REST-assured. +- Require shared Java types to stay in the JDK-only `pickleball-control-protocol`; worker bridge behavior and runtime translation stay in core. +- Require the Workbench artifact/process to be core-free and the separate worker to load Pickleball only from the consumer's captured test-runtime classpath. +- Require dependency provenance, nested JAR/service scans, distinct PID, runtime code-source/version checks, worker exclusion of the controller artifact, and clear incompatibility failure. +- Require the outer Pickleball JAR to contain exactly one byte-identical opaque Workbench payload without flattened Workbench/MCP classes. Pickleball may contain Workbench; Workbench must not contain Pickleball. + ## Documentation and maintained context - Flag changes to behavior, syntax, inputs, outputs, defaults, constraints, errors, edge cases, or compatibility that do not update the canonical documentation. diff --git a/build.gradle b/build.gradle index 9d95a9bb..5015172d 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,10 @@ configurations { } dependencies { + // Shared wire DTOs/constants only. The protocol module is JDK-only and its + // classes are embedded in the published Pickleball artifact below. + implementation project(':pickleball-control-protocol') + api("io.cucumber:cucumber-junit-platform-engine:${cucumberVersion}") { // Prevent consumers from pulling the UNWOVEN cucumber internals transitively exclude group: "io.cucumber", module: "cucumber-core" @@ -142,6 +146,7 @@ tasks.withType(JavaCompile).configureEach { tasks.register('ajcMain', JavaExec) { group = 'build' description = 'Compile Java + AspectJ (main) with ajc' + dependsOn ':pickleball-control-protocol:jar' def outDir = sourceSets.main.java.destinationDirectory.get().asFile inputs.files(sourceSets.main.java) @@ -246,6 +251,10 @@ tasks.register('expandWoven') { def myLicense = file("LICENSE").canonicalFile def myNotice = file("NOTICE").canonicalFile +def embeddedWorkbenchResource = 'META-INF/pickleball/workbench/pickleball-workbench.jar' +def standaloneWorkbenchJar = project(':pickleball-workbench').layout.buildDirectory.file( + "libs/pickleball-workbench-${project.version}.jar" +) tasks.shadowJar { configurations = [project.configurations.ajcRuntime] @@ -285,12 +294,20 @@ tasks.shadowJar { from("NOTICE") { into "META-INF" } from("THIRD-PARTY-NOTICES.md") { into "META-INF" } - dependsOn 'ajcMain', 'expandWoven', 'classes' + dependsOn 'ajcMain', 'expandWoven', 'classes', ':pickleball-workbench:shadowJar' archiveBaseName.set('pickleball') archiveVersion.set(project.version.toString()) archiveClassifier.set('') + manifest { + attributes( + 'Implementation-Title': 'Pickleball', + 'Implementation-Version': project.version.toString(), + 'Pickleball-Workbench-Resource': embeddedWorkbenchResource + ) + } + from(sourceSets.main.output) { includeEmptyDirs = false } @@ -304,12 +321,85 @@ tasks.shadowJar { // put woven classes LAST so they overwrite any originals from(wovenExpanded) + // Workbench is delivered outward as one opaque controller-only executable. + // Never expand this payload into the consumer-visible runtime namespace. + from(standaloneWorkbenchJar) { + into 'META-INF/pickleball/workbench' + rename { 'pickleball-workbench.jar' } + } + mergeServiceFiles() exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA' zip64 = true } +tasks.register('verifyEmbeddedWorkbench') { + group = 'verification' + description = 'Verifies the outer Pickleball JAR embeds exactly the controller-only Workbench payload.' + dependsOn tasks.shadowJar, ':pickleball-workbench:verifyWorkbenchArtifact' + + doLast { + File outerFile = tasks.shadowJar.get().archiveFile.get().asFile + File standaloneFile = standaloneWorkbenchJar.get().asFile + byte[] standaloneBytes = standaloneFile.bytes + + new java.util.jar.JarFile(outerFile).withCloseable { outer -> + def payloadEntries = java.util.Collections.list(outer.entries()).findAll { entry -> + !entry.directory && entry.name.startsWith('META-INF/pickleball/workbench/') + } + if (payloadEntries*.name != [embeddedWorkbenchResource]) { + throw new GradleException( + "Expected exactly one opaque Workbench payload at ${embeddedWorkbenchResource}: " + + payloadEntries*.name + ) + } + + byte[] embeddedBytes = outer.getInputStream(payloadEntries.first()).readAllBytes() + if (!java.util.Arrays.equals(standaloneBytes, embeddedBytes)) { + throw new GradleException( + 'Embedded Workbench payload bytes do not match the standalone controller build.' + ) + } + + def flattened = java.util.Collections.list(outer.entries()).findAll { entry -> + entry.name.startsWith('tools/dscode/workbench/') || + entry.name.startsWith('io/modelcontextprotocol/') + } + if (!flattened.isEmpty()) { + throw new GradleException( + 'Workbench/controller dependencies were flattened into Pickleball: ' + + flattened*.name.take(20) + ) + } + + new java.util.jar.JarInputStream(new ByteArrayInputStream(embeddedBytes)).withCloseable { nested -> + def manifest = nested.manifest + if (manifest?.mainAttributes?.getValue('Main-Class') + != 'tools.dscode.workbench.WorkbenchApplication') { + throw new GradleException('Embedded Workbench payload has an unexpected Main-Class.') + } + } + } + } +} + +tasks.register('verifyStrictControllerIsolation') { + group = 'verification' + description = 'Verifies the neutral protocol, controller-only Workbench, and opaque outer payload.' + dependsOn( + ':pickleball-control-protocol:verifyProtocolIsolation', + ':pickleball-workbench:verifyWorkbenchArtifact', + ':pickleball-workbench:verifyWorkbenchRuntimeBoundary', + ':pickleball-workbench:verifyWorkbenchPublishedDependencyContract', + tasks.named('verifyEmbeddedWorkbench') + ) +} + +tasks.named('check') { + dependsOn tasks.named('verifyStrictControllerIsolation') +} + // Disable the plain jar so only the shaded jar is produced/published tasks.jar { enabled = false } diff --git a/docs/README.md b/docs/README.md index d1f8be4c..106dfc6e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,7 +37,7 @@ When these docs are materialized from the Maven dependency with `DiagnosticCli e - [AI and automation run configuration](ai-run-configuration.md) — controlled `pkb_runvars`, inherited execution context, retained `pkb_run_profile`, `pkb_configpath`, protected values, and deterministic diagnostic reruns. - [Dynamic control API](dynamic-control-api.md) — optional retry-friendly dynamic Gherkin execution, isolated/scoped ParsingMap control, snapshots, value interception, synchronous semantic hooks, and the consumer-side Control Bridge used by Workbench. - [Step Overrides](step-overrides.md) — live REGEX/REPLACE step implementation authoring in a persistent worker, including generated Java handlers and Workbench management. -- [Pickleball Workbench](pickleball-workbench.md) — separate executable companion with synchronization, persistent live worker control, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and thin Swing UI. +- [Pickleball Workbench](pickleball-workbench.md) — dependency-matched external controller embedded opaquely in Pickleball, with a neutral versioned protocol, strict core-free artifact/process boundary, consumer-classpath worker, MCP stdio, and Swing UI. - [Diagnostic lineage and metadata](diagnostic-lineage-metadata.md) — distinguish lineage annotations, execution/evidence RunVars, controls, and derived evidence. - [Diagnostic reporting](diagnostic-reporting.md) — sparse-first AI evidence, source provenance, step/capability metadata, trace evidence, screenshots/fingerprints, comparison, and retention. - [AI diagnostic reporting plan](ai-diagnostic-reporting-plan.md) — current sparse-first investigation and controlled-rerun architecture. diff --git a/docs/agent/README.md b/docs/agent/README.md index 664905ed..b3324a47 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -10,6 +10,7 @@ This directory supports repository-native AI coding agents. It is not a runtime - `/docs/agent/feature-map.md` — living map from capabilities to implementation, tests, consumer examples, and documentation - `/docs/agent/change-checklist.md` — explicit change-completion checklist - `/docs/agent/repository-index.md` — generated inventory of relevant files +- `/pickleball-workbench/AGENTS.md` — strict controller/core dependency, artifact, process, worker-classpath, protocol, and focused-test invariants - `/docs/ai-run-configuration.md` — controlled execution through `pkb_runvars`, canonical `pkb_run_profile`, inherited execution context, `pkb_configpath`, replay, and protected values - `/docs/diagnostic-lineage-metadata.md` — investigation lineage and derived diagnostic metadata - `/REVIEW.md` — review-time checks for compatibility, tests, consumer examples, and documentation omissions @@ -58,6 +59,8 @@ A functionality-change agent should: 7. Remove disposable working files. 8. Report results. +Workbench work has an additional hard boundary: `pickleball-workbench` may share only the JDK-only `pickleball-control-protocol`; all execution remains in the consumer worker. The outer Pickleball JAR may carry the completed Workbench as opaque bytes, but Workbench must never contain or load Pickleball. Future agents must not restore the removed root/published-equivalent dependency. Use `verifyStrictControllerIsolation` and focused `@control-bridge` / `@step-override-bridge` scenarios with `pkb_parallel=80`, never `@all`, for this boundary. + For AI-launched tests with known settings, default to `pkb_runvars`. Use ordinary JVM RunVars or named profiles instead only when intentionally exercising those resolution paths. For controlled reruns, follow `/docs/ai-run-configuration.md` and `/docs/diagnostic-lineage-metadata.md`: replay retained `runProfile` through `pkb_runvars`, change only intentional RunVars, keep lineage separate, and verify `runProfileFingerprint`. `pkb_changed_variables` names RunVars only, not source changes or profile controls. This is task-time automation, not a passive background documentation watcher. @@ -93,6 +96,16 @@ Windows: .\scripts\agent_validate.ps1 ``` +Workbench/controller isolation uses the dedicated focused mode, which runs strict artifact/dependency checks and then runs `@control-bridge` and `@step-override-bridge` sequentially, each with `pkb_parallel=80`: + +```shell +scripts/agent_validate.sh --workbench +``` + +```powershell +.\scripts\agent_validate.ps1 -Workbench +``` + ## Enforcement levels By default, `verify_agent_contract.py` treats missing agent files and invalid temporary-workspace configuration as errors; change-coverage findings remain warnings. Strict mode may be used in CI when appropriate. diff --git a/docs/agent/change-checklist.md b/docs/agent/change-checklist.md index faed2328..c18962f3 100644 --- a/docs/agent/change-checklist.md +++ b/docs/agent/change-checklist.md @@ -27,6 +27,8 @@ Use this checklist for changes to Pickleball behavior. Coding agents should comp - [ ] Cover meaningful edge and compatibility cases. - [ ] When an agent launches Pickleball tests with known execution settings, use `pkb_runvars` as the authoritative input unless the test intentionally exercises normal JVM/profile precedence. - [ ] Never supply `pkb_run_profile` as test input; it is derived output. +- [ ] For Workbench/protocol/worker changes, preserve the JDK-only shared protocol, core-free controller artifact/process, separate consumer worker, consumer-authoritative classpath, and opaque nested payload. +- [ ] Never restore a root/`tools.dscode:pickleball`/behavioral-control dependency to Workbench to fix compilation. ## Maintain knowledge @@ -42,7 +44,10 @@ Use this checklist for changes to Pickleball behavior. Coding agents should comp - [ ] Run `python scripts/sync_consumer_guidance.py --check`. - [ ] Run `./gradlew test`. - [ ] For consumer-visible changes, run `./gradlew publishToMavenLocal`. -- [ ] For consumer-visible changes, run `./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_tags=@all`. +- [ ] For broad consumer-visible changes outside Workbench/controller isolation, run `./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_tags=@all`. +- [ ] For Workbench/controller isolation changes, never run `@all`; run only affected `@control-bridge` and/or `@step-override-bridge` scenarios with `-Dpkb_runvars.pkb_parallel=80` where practical. +- [ ] For Workbench boundary changes, run `./gradlew verifyStrictControllerIsolation :pickleball-workbench:test`. +- [ ] Prefer the equivalent focused turnkey command `scripts/agent_validate.sh --workbench` (PowerShell: `.\scripts\agent_validate.ps1 -Workbench`) when the environment supports the complete flow. - [ ] Report anything not run and the reason. ## Report diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 87122098..c5376dae 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -4,9 +4,11 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Capability | Implementation/search anchors | Consumer/internal coverage | Canonical documentation | |---|---|---|---| -| Build, publication, Java compatibility | `build.gradle`; `settings.gradle`; `src/main/aspectj`; `gradle/pickleball-published-variant.gradle`; search `publishing`, `shadowJar`, `pickleballPublishedElements`, `aspectj`, `JavaLanguageVersion` | root tests; `:pickleball-workbench:test`; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `gradle/pickleball-published-variant.gradle`; `WorkbenchApplication`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; Pickleball `WorkbenchWorkerMain`; `DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY` | `:pickleball-workbench:test`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; `DynamicSuiteBootstrapWorkbenchRootTest`; direct `sync` / `worker-check` / `live-check`; packaged UI smoke | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer-side Control Bridge and live investigation | `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core bootstrap `ControlRuntime`; Workbench `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | +| Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -29,18 +31,22 @@ This file maps consumer-visible capabilities to implementation anchors, executab ## Workbench architecture contract -`pickleball-workbench` is a separate executable companion module and depends one-way on the normal Pickleball runtime. The repository build supplies the Workbench through `pickleballPublishedElements`, a dedicated published-equivalent configuration backed by the root shaded/woven `shadowJar` plus the root publication's non-bundled external runtime dependencies. Do not replace that dependency with a naïve root `project(':')` variant and do not expose `pickleball-control-api` as a Workbench publication dependency. +`pickleball-workbench` is an external controller, not a test runtime. Core/worker and Workbench both use the JDK-only `pickleball-control-protocol`; Workbench has no root Pickleball, Maven Pickleball, published-equivalent, or behavioral `pickleball-control-api` dependency. Never resolve a Workbench compilation problem by restoring one of those dependencies or shading core. Pickleball may contain Workbench; Workbench must not contain Pickleball. -The Workbench POM contract is exactly `tools.dscode:pickleball`; Workbench-only implementation libraries are shaded into the executable companion. Build verification checks both directions: the Workbench resolves the shaded Pickleball artifact without separate unwoven Cucumber modules, and the normal Pickleball JAR contains no Workbench classes/resources. +The controller-side `ControlBridgeClient` uses only `tools.dscode.control.protocol.*`. The consumer-hosted `ControlBridgeRuntime`, `ControlBridgeCoordinator`, bootstrap, runtime adapters, Cucumber/Selenium/service behavior, mappings, and Step Override compilation remain in Pickleball. `ControlProtocol` owns protocol version/minimum-version negotiation, capabilities, wire constants, and the worker main-class string; it owns no behavior. -The controller-side `ControlBridgeClient` uses the public `tools.dscode.control.bridge.*` records bundled in `tools.dscode:pickleball`; it does not duplicate parsing, mapping, browser, service-call, breakpoint, Step Override, or detached-execution semantics. The consumer-hosted `ControlBridgeRuntime` and `ControlBridgeCoordinator` remain in Pickleball. +The standalone Workbench shadow JAR is controller-only and self-contained, with an empty published dependency list. Root `shadowJar` consumes the finished file as opaque bytes at `META-INF/pickleball/workbench/pickleball-workbench.jar`. `PickleballWorkbenchLauncher` extracts by SHA-256 under the consumer's `.pickleball/workbench/controller/` state and always starts `java -jar` in a distinct JVM. Workbench/MCP classes are not flattened into the outer Pickleball namespace, and Pickleball/core classes are not present in the nested Workbench. + +`verifyProtocolIsolation`, `verifyWorkbenchRuntimeBoundary`, `verifyWorkbenchArtifact`, `verifyWorkbenchPublishedDependencyContract`, `verifyEmbeddedWorkbench`, and `verifyStrictControllerIsolation` enforce dependency provenance, top-level/nested JAR entries, service providers, exact payload count/bytes, and entry points. `WorkbenchRuntimeBoundary` rejects core visibility in the controller process. `WorkbenchWorkerManager` rejects same-PID workers, non-consumer runtime origins, synchronized-version drift, and any worker classpath containing the Workbench controller. The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `PKB_CONTROL_BRIDGE_SESSION_ID`, `PKB_CONTROL_BRIDGE_TOKEN`, and `PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO`. Pickleball may also accept the prior `PKB_STUDIO_BRIDGE_*` names as deprecated compatibility input aliases. New Workbench code must use only the neutral names. `WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. -`WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through Pickleball's thin `WorkbenchWorkerMain`, without invoking Maven or Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. +`WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through the protocol-owned worker class-name contract, without linking the worker class or invoking Maven/Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the session buffer, stable line identities, selection, playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. + +For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 118cfbec..ebb90e76 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -362,6 +362,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/coredefinitions/TableSteps.java` - `src/main/java/tools/dscode/coredefinitions/UtilitySteps.java` - `src/main/java/tools/dscode/cucumberextended/utilities/StringUtilities.java` +- `src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java` - `src/main/java/tools/dscode/misc/DummySteps.java` - `src/main/java/tools/dscode/parallelutilities/Stagger.java` - `src/main/java/tools/dscode/pickleruntime/CucumberOptionResolver.java` @@ -385,6 +386,7 @@ This inventory helps coding agents discover relevant files. It does not replace ## Framework tests - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` +- `src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` ## Control API module @@ -435,6 +437,38 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValue.java` - `pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValueResult.java` +## Neutral control protocol module + +- `pickleball-control-protocol/build.gradle` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBoundedJsonEvidence.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBreakpoint.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPage.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPageResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshot.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshotResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeCallResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeDescriptor.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementEvidence.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspection.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspectionResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeError.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEvent.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEventPage.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeJson.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshot.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshotResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeRequests.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeResponses.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeScenarioStatus.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallEvidence.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStatus.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverride.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverrideResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java` + ## Pickleball Workbench module - `gradle/pickleball-published-variant.gradle` @@ -452,6 +486,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java` @@ -463,6 +498,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchLiveSessionTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java` @@ -563,7 +599,6 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/files/customers.yaml` - `maven-consumer-project/src/test/resources/pickleball.properties` - `maven-consumer-project/src/test/resources/pickleball_local.properties` -- `maven-consumer-project/src/test/resources/pickleball_local2.properties` - `maven-consumer-project/src/test/resources/profiles.yaml` - `maven-consumer-project/src/test/resources/profiles_local.yaml` diff --git a/docs/consumer-project.md b/docs/consumer-project.md index b9cf6e95..c4954679 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -75,6 +75,23 @@ or use the included wrappers: `PickleballTests` starts the test server on `127.0.0.1:8765` before Cucumber and stops it afterward. +## Launch the dependency-matched Workbench + +The test-scoped Pickleball dependency already contains its controller-only Workbench payload. Start the launcher from the resolved test classpath without finding a Maven cache entry or declaring a second version: + +```bash +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +```powershell +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +``` + +The launcher verifies and extracts the opaque payload beneath `.pickleball/workbench/controller//`, then creates a separate Workbench JVM. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. UI and MCP both use the same controller/live session. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. + Runner defaults include: - glue `com.example.pickleball`; @@ -128,6 +145,8 @@ The executable project covers Selenium navigation/selection/actions/assertions/d Common suite tags include `@all`, `@regression`, `@smoke`, `@browser`, and `@data`. Functional areas include `@navigation`, `@forms`, `@catalog`, `@mapping`, `@resources`, `@workflow`, `@keyboard`, `@dialogs`, and `@components`. +Controller/protocol migration checks must remain focused: use `@control-bridge` and/or `@step-override-bridge`, set `pkb_parallel=80` when practical, and do not run `@all` for Workbench isolation work. + ```bash mvn test -Dpkb_tags="@forms and @state-assertions" mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" diff --git a/docs/dynamic-control-api.md b/docs/dynamic-control-api.md index ab2890b3..d8e43c86 100644 --- a/docs/dynamic-control-api.md +++ b/docs/dynamic-control-api.md @@ -1,6 +1,6 @@ # Dynamic Control API -Pickleball exposes a small core interception contract plus a separately organized `pickleball-control-api` source module for dynamic tooling. The control API classes are bundled into the main `tools.dscode:pickleball` artifact; consumers do not add a second Maven dependency. The source module is intentionally independent of MCP, Spring AI, GUIs, and process orchestration. +Pickleball exposes a small core interception contract plus a separately organized `pickleball-control-api` source module for dynamic tooling. The behavioral control API classes are bundled into the main `tools.dscode:pickleball` artifact; consumers do not add a second Maven dependency. Versioned wire records live separately in the JDK-only `pickleball-control-protocol` module so the Workbench controller never depends on behavioral runtime classes. ## Artifact and compatibility @@ -39,6 +39,8 @@ For backward compatibility, Pickleball may accept the former `PKB_STUDIO_BRIDGE_ Each participating consumer JVM binds to `127.0.0.1` on an operating-system-assigned port and writes a runtime descriptor into the session directory. Requests require the session bearer token and responses are marked `Cache-Control: no-store`. +The descriptor advertises the current and minimum-compatible protocol versions, capabilities, PID, Pickleball implementation version, and runtime code source. Workbench rejects incompatible capabilities/versions, a same-process worker, a runtime origin outside the synchronized consumer classpath, version drift, or a worker classpath containing the controller. It never falls back to executing a Workbench-bundled runtime. + The bridge keeps live operations on the real scenario thread through `ControlBridgeCoordinator`. This preserves access to thread-local Cucumber/Pickleball state, glue, browser, services, mappings, and other scenario resources. Bridge capabilities include: @@ -54,7 +56,7 @@ Bridge capabilities include: - semantic breakpoint management; - scenario-scoped Step Override management. -Workbench owns the controller-side bridge client. MCP and Swing access these capabilities through `WorkbenchServices` / `WorkbenchController`; they do not connect to the bridge independently or implement a second runtime. +Workbench owns the controller-side protocol client. MCP and Swing access these capabilities through `WorkbenchServices` / `WorkbenchController`; they do not connect to the bridge independently or implement a second runtime. Workbench imports only `tools.dscode.control.protocol.*`; bridge server/coordinator/bootstrap and conversion to runtime objects remain worker-side. ## Scenario targeting and finite pauses @@ -86,4 +88,4 @@ Worker-side compilation requires `javax.tools.JavaCompiler`. Workbench sends a J ## Architecture boundary -The control API and bridge intentionally remain independent of MCP, Spring, GUI frameworks, generic project IDE behavior, and build orchestration. Pickleball owns live execution semantics. Workbench is the external controller/adaptation layer. +The control API and bridge intentionally remain independent of MCP, Spring, GUI frameworks, generic project IDE behavior, and build orchestration. Pickleball owns live execution semantics. Workbench is the external controller/adaptation layer and its artifact contains no Pickleball core, behavioral control API, Cucumber, Selenium, or service runtime. The only shared code is the dependency-neutral wire protocol. diff --git a/docs/getting-started.md b/docs/getting-started.md index e3f8091c..00860eeb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -15,7 +15,7 @@ A consumer normally needs the Pickleball test dependency and one runner extendin ```xml 21 - 2.1.5 + 2.1.9 @@ -83,6 +83,19 @@ Named profile definitions use the same shared/local idea: define shared profiles mvn test ``` +## Launch the matching Workbench + +The Pickleball dependency carries its version-matched, controller-only Workbench as an opaque nested executable. Launch it from the consumer test classpath; do not add or version a second Workbench dependency: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +The launcher extracts verified bytes beneath `.pickleball/workbench/controller//` and starts a separate controller JVM. Workbench then synchronizes the project and starts a second, consumer-owned worker JVM from the resolved test runtime. Core, Cucumber, Selenium, service behavior, mappings, and steps execute only in that worker. See [Pickleball Workbench](pickleball-workbench.md). + Filter normally with RunVars such as: ```bash diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index b6237d54..e0f5d466 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -1,20 +1,26 @@ # Pickleball Workbench -Pickleball Workbench is the separate executable companion for interactive Pickleball execution and investigation. It depends on the normal shaded/woven `tools.dscode:pickleball` artifact; normal Pickleball consumers do not depend on Workbench. +Pickleball Workbench is the external controller for interactive Pickleball execution and investigation. Its executable contains controller code, GUI/MCP adapters, synchronization support, JSON transport, and the neutral wire protocol—but no Pickleball core/runtime. Real execution occurs only in a separate consumer worker using the consumer project's compiled output and resolved test runtime. Workbench replaces the former Pickleball Studio application. The supported architecture is deliberately execution-oriented: project synchronization, a persistent consumer worker, live runtime control, Mapping, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and a Swing UI over the same service seam. ## Architecture -Dependency direction is strictly: +Source dependencies and distribution are strictly separated: ```text -pickleball-workbench -> pickleball +pickleball core/worker --------> JDK-only control protocol +pickleball-workbench ----------> JDK-only control protocol +published pickleball JAR ------> opaque Workbench executable bytes ``` +The distribution arrow is an assembly input, not a Workbench-to-core Java dependency. **Pickleball may contain Workbench; Workbench must not contain Pickleball.** Separate JVMs are required, but they are not sufficient: dependency graphs, class visibility, JAR entries, nested JARs, service providers, and runtime origins are checked too. + Pickleball owns scenario execution semantics, Cucumber integration, DynamicControl/Gherkin execution, Mapping, browser/service behavior, the consumer-side Control Bridge, semantic hooks/breakpoints, Step Overrides, and woven Cucumber/AspectJ behavior. -Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the controller-side bridge client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, the headless live-scenario presentation model, and the Swing adapter. +Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the protocol client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, the headless live-scenario presentation model, and the Swing adapter. It does not import the worker entry point; it launches the protocol's class-name string on the captured consumer classpath. + +`pickleball-control-protocol` owns only immutable wire records, request/response envelopes, transport constants, capabilities, and version/minimum-version negotiation. Worker-side bridge server/coordinator/bootstrap and all translation to runtime operations remain in Pickleball core. MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. @@ -29,12 +35,55 @@ PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO Pickleball may accept `PKB_STUDIO_BRIDGE_*` as deprecated compatibility input aliases only. Workbench emits only the neutral names. -## Build and run +## Launch from a consumer project + +The normal `tools.dscode:pickleball:` test dependency already carries the matching controller at: -Build the executable companion: +```text +META-INF/pickleball/workbench/pickleball-workbench.jar +``` + +Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` ```powershell -.\gradlew.bat :pickleball-workbench:build +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +``` + +With no launcher arguments, `ui` and the current directory are selected automatically. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."` or `"-Dexec.args=mcp ."`. + +Gradle consumers can expose the same dependency-owned launcher without resolving a cache path or adding a Workbench dependency: + +```groovy +tasks.register('pickleballWorkbench', JavaExec) { + classpath = sourceSets.test.runtimeClasspath + mainClass = 'tools.dscode.launcher.PickleballWorkbenchLauncher' + args 'ui', projectDir.absolutePath +} +``` + +Run it with `./gradlew pickleballWorkbench` (or `gradlew.bat pickleballWorkbench`). The task uses the consumer's resolved test runtime only to locate the tiny launcher and nested bytes; actual controller code still starts in a separate `java -jar` process. + +The launcher reads the nested payload, limits its size, calculates SHA-256, and extracts it atomically to: + +```text +.pickleball/workbench/controller//pickleball-workbench.jar +``` + +It verifies existing/extracted bytes, starts `java -jar` in a new Workbench JVM, inherits stdio, and propagates non-zero exit status. The content-addressed path prevents a stale payload from silently replacing the version carried by the consumer dependency. + +## Maintainer build and direct run + +Build the standalone controller and strict isolation checks: + +```powershell +.\gradlew.bat :pickleball-workbench:build verifyStrictControllerIsolation ``` The executable is: @@ -43,7 +92,7 @@ The executable is: pickleball-workbench/build/libs/pickleball-workbench-.jar ``` -Synchronize a consumer project before starting a worker manually: +Synchronize a consumer project before starting a worker manually from repository output: ```powershell $workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" @@ -52,6 +101,8 @@ java -jar $workbenchJar sync ".\maven-consumer-project" Synchronization uses the selected project wrapper to establish compiled output and the effective test runtime classpath. `.pickleball/workbench/base/classes` is provenance only; the worker runs against the merged `.pickleball/workbench/live/classes` state plus captured external dependencies. +At worker connection time, Workbench requires a different PID, compatible protocol range and capabilities, a Pickleball code source that is exactly one captured consumer classpath entry, the synchronized Pickleball version (except explicit development output), and no Workbench controller artifact on the worker classpath. It fails clearly instead of falling back to a bundled runtime. + ## Swing UI Start Workbench for one consumer project: @@ -259,14 +310,18 @@ Workbench MCP and Swing intentionally do not expose a generic IDE or build syste ## Dependency and artifact checks -The Workbench build keeps the published-equivalent Pickleball boundary and verifies that: +The build proves the controller boundary with `verifyStrictControllerIsolation` and its component tasks: -- the Workbench executable contains the MCP adapter; -- normal Pickleball contains neither Workbench classes nor MCP SDK classes; -- Workbench does not resolve the unpublished `pickleball-control-api` project; -- separate unwoven Cucumber modules do not appear on the Workbench runtime; +- `pickleball-control-protocol` has no non-JDK dependency; +- the only Workbench project dependency is `pickleball-control-protocol`; +- the Workbench compile/runtime graph contains no root Pickleball, behavioral control API, Cucumber, Selenium, or REST-assured path; +- the Workbench executable contains its controller, GUI, MCP, protocol client, and runtime isolation guard; +- top-level and nested Workbench entries and service descriptors contain no core/worker implementation or nested Pickleball runtime; +- the published Workbench POM has no dependencies; - the MCP convenience artifact / Jackson 3 path is not used; -- the published Workbench POM still declares only `tools.dscode:pickleball`. +- the outer Pickleball JAR contains exactly one opaque Workbench payload whose bytes equal the standalone output; +- Workbench/MCP entries are not flattened into the outer runtime namespace; and +- the nested payload has the expected Workbench `Main-Class`. Report the executable size and resolved MCP SDK artifacts with: @@ -308,6 +363,12 @@ For the player state model and Swing/controller behavior: .\gradlew.bat :pickleball-workbench:test ``` +For protocol, dependency, nested-artifact, and process-boundary checks: + +```powershell +.\gradlew.bat verifyStrictControllerIsolation +``` + For shared controller/MCP behavior: ```powershell @@ -323,3 +384,12 @@ java -jar $workbenchJar sync ".\maven-consumer-project" java -jar $workbenchJar worker-check ".\maven-consumer-project" java -jar $workbenchJar live-check ".\maven-consumer-project" ``` + +For changed consumer bridge behavior, use only the affected focused tags—never `@all`—and use parallelism 80 where practical: + +```powershell +.\maven-consumer-project\mvnw.cmd -f maven-consumer-project\pom.xml -U test "-Dpkb_runvars.pkb_browser=CHROME_HEADLESS" "-Dpkb_runvars.pkb_parallel=80" "-Dpkb_runvars.pkb_tags=@control-bridge" +.\maven-consumer-project\mvnw.cmd -f maven-consumer-project\pom.xml -U test "-Dpkb_runvars.pkb_browser=CHROME_HEADLESS" "-Dpkb_runvars.pkb_parallel=80" "-Dpkb_runvars.pkb_tags=@step-override-bridge" +``` + +Run these invocations sequentially because the scenarios deliberately verify the process-global bridge bootstrap. diff --git a/docs/step-overrides.md b/docs/step-overrides.md index 0992d7cf..b0793a15 100644 --- a/docs/step-overrides.md +++ b/docs/step-overrides.md @@ -96,4 +96,4 @@ The focused consumer tag is: @step-override ``` -Run it with the normal parallel consumer acceptance settings. Workbench `live-check` also compiles an override, executes override-only Gherkin, replaces the generated implementation, removes it, verifies fallback behavior, and confirms the same persistent worker context was retained. +For a bridge/protocol-only change, the smallest tag is `@step-override-bridge`; use `@step-override` when worker matching/compiler semantics changed too. Set `pkb_parallel=80` when practical and do not substitute `@all` for focused Workbench validation. Workbench `live-check` also compiles an override, executes override-only Gherkin, replaces the generated implementation, removes it, verifies fallback behavior, and confirms the same persistent worker context was retained. diff --git a/gradle/pickleball-published-variant.gradle b/gradle/pickleball-published-variant.gradle index 94e44741..ea105432 100644 --- a/gradle/pickleball-published-variant.gradle +++ b/gradle/pickleball-published-variant.gradle @@ -1,83 +1,4 @@ -import org.gradle.api.artifacts.ExternalModuleDependency -import org.gradle.api.attributes.Category -import org.gradle.api.attributes.LibraryElements -import org.gradle.api.attributes.Usage -import org.gradle.api.attributes.java.TargetJvmVersion - -// Internal repository-only variant used by pickleball-workbench. It exposes -// the actual shaded/woven Pickleball JAR plus the same external runtime modules -// that the published Pickleball POM exposes. It intentionally does not expose -// the root project's ordinary pre-publication runtimeElements variant. -def bundledOrOverriddenGA = [ - 'org.aspectj:aspectjrt', - 'io.cucumber:cucumber-bom', - 'io.cucumber:cucumber-core', - 'io.cucumber:cucumber-gherkin', - 'io.cucumber:cucumber-gherkin-messages', - 'io.cucumber:cucumber-java', - 'io.cucumber:cucumber-plugin', - 'io.cucumber:messages', - 'io.cucumber:gherkin' -] as Set - -pluginManager.withPlugin('java-library') { - def publishedElements = configurations.maybeCreate('pickleballPublishedElements') - publishedElements.canBeConsumed = true - publishedElements.canBeResolved = false - publishedElements.visible = false - publishedElements.description = 'Published-equivalent shaded/woven Pickleball runtime for repository companion tools.' - publishedElements.attributes { - attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME)) - attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY)) - attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, LibraryElements.JAR)) - attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21) - } - - pluginManager.withPlugin('com.gradleup.shadow') { - publishedElements.outgoing.artifact(tasks.named('shadowJar')) { - builtBy tasks.named('shadowJar') - } - } - - afterEvaluate { - // Mirror the root publication's external dependency contract without - // exporting local file dependencies or the unpublished control module. - // ExternalModuleDependency.copy() retains version constraints and - // per-dependency excludes (notably cucumber-junit-platform-engine). - configurations.runtimeClasspath.allDependencies - .findAll { it instanceof ExternalModuleDependency } - .each { ExternalModuleDependency dependency -> - def ga = "${dependency.group}:${dependency.name}" - if (!bundledOrOverriddenGA.contains(ga)) { - dependencies.add(publishedElements.name, dependency.copy()) - } - } - } - - tasks.register('verifyPickleballPublishedElements') { - group = 'verification' - description = 'Verifies the repository-only published-equivalent Pickleball variant.' - - doLast { - def forbidden = publishedElements.allDependencies.findAll { dependency -> - dependency.group != null && bundledOrOverriddenGA.contains("${dependency.group}:${dependency.name}") - } - if (!forbidden.isEmpty()) { - throw new GradleException( - 'Published-equivalent Pickleball variant exposes bundled/woven modules: ' + - forbidden.collect { "${it.group}:${it.name}" }.sort() - ) - } - - def projectDependencies = publishedElements.allDependencies.findAll { - it instanceof org.gradle.api.artifacts.ProjectDependency - } - if (!projectDependencies.isEmpty()) { - throw new GradleException( - 'Published-equivalent Pickleball variant must not expose project dependencies: ' + - projectDependencies.collect { it.path }.sort() - ) - } - } - } -} +// Retained as a migration tombstone for drop-in upgrades. Workbench no longer +// resolves any published-equivalent/root runtime variant. Its only shared Java +// dependency is :pickleball-control-protocol; the completed controller JAR is +// embedded outward into the Pickleball distribution as opaque bytes. diff --git a/pickleball-control-api/build.gradle b/pickleball-control-api/build.gradle index cea997f2..488f6c5d 100644 --- a/pickleball-control-api/build.gradle +++ b/pickleball-control-api/build.gradle @@ -14,6 +14,8 @@ java { } dependencies { + implementation project(':pickleball-control-protocol') + // Internal source-module dependency only. Consumers receive these classes // from the main shaded Pickleball artifact. api project(':') diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBootstrap.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBootstrap.java index e55540da..3a0c68ce 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBootstrap.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBootstrap.java @@ -1,14 +1,17 @@ package tools.dscode.control.bridge; +import tools.dscode.control.protocol.ControlBridgeDescriptor; +import tools.dscode.control.protocol.ControlProtocol; + import java.nio.file.Path; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; public final class ControlBridgeBootstrap { - public static final String ENV_SESSION_DIR = "PKB_CONTROL_BRIDGE_SESSION_DIR"; - public static final String ENV_SESSION_ID = "PKB_CONTROL_BRIDGE_SESSION_ID"; - public static final String ENV_TOKEN = "PKB_CONTROL_BRIDGE_TOKEN"; - public static final String ENV_PAUSE_FIRST_SCENARIO = "PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO"; + public static final String ENV_SESSION_DIR = ControlProtocol.SESSION_DIRECTORY_ENV; + public static final String ENV_SESSION_ID = ControlProtocol.SESSION_ID_ENV; + public static final String ENV_TOKEN = ControlProtocol.SESSION_TOKEN_ENV; + public static final String ENV_PAUSE_FIRST_SCENARIO = ControlProtocol.PAUSE_FIRST_SCENARIO_ENV; private static final String LEGACY_ENV_SESSION_DIR = "PKB_STUDIO_BRIDGE_SESSION_DIR"; private static final String LEGACY_ENV_SESSION_ID = "PKB_STUDIO_BRIDGE_SESSION_ID"; diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBreakpoint.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBreakpoint.java index c1caa0b8..3ed17dd8 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBreakpoint.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBreakpoint.java @@ -1,5 +1,7 @@ package tools.dscode.control.bridge; +/** @deprecated Wire controllers use {@code tools.dscode.control.protocol}. */ +@Deprecated(forRemoval = false) public record ControlBridgeBreakpoint( String breakpointId, String scenarioId, diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPage.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPage.java index dd9cbef9..73b3d839 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPage.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPage.java @@ -2,7 +2,8 @@ import java.util.List; -/** Bounded read-only evidence from the browser already owned by one scenario. */ +/** @deprecated Wire controllers use {@code tools.dscode.control.protocol}. */ +@Deprecated(forRemoval = false) public record ControlBridgeBrowserPage( String url, String title, diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPageResult.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPageResult.java index 5dc7887e..edbb0140 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPageResult.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeBrowserPageResult.java @@ -1,6 +1,7 @@ package tools.dscode.control.bridge; -/** Logical result of reading current browser page evidence. */ +/** @deprecated Wire controllers use {@code tools.dscode.control.protocol}. */ +@Deprecated(forRemoval = false) public record ControlBridgeBrowserPageResult( String status, ControlBridgeBrowserPage page, diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeCoordinator.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeCoordinator.java index 17492187..1b8fade0 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeCoordinator.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeCoordinator.java @@ -15,12 +15,34 @@ import tools.dscode.common.mappings.NodeMap; import tools.dscode.common.treeparsing.parsedComponents.Phrase; import tools.dscode.control.api.ControlCallResult; +import tools.dscode.control.api.BoundedJsonEvidence; import tools.dscode.control.api.DynamicControl; import tools.dscode.control.api.ElementControl; +import tools.dscode.control.api.ElementEvidence; import tools.dscode.control.api.ElementInspection; import tools.dscode.control.api.MappingControl; import tools.dscode.control.api.ServiceCallControl; import tools.dscode.control.api.ServiceCallEvidence; +import tools.dscode.control.protocol.ControlBridgeBoundedJsonEvidence; +import tools.dscode.control.protocol.ControlBridgeBreakpoint; +import tools.dscode.control.protocol.ControlBridgeBrowserPage; +import tools.dscode.control.protocol.ControlBridgeBrowserPageResult; +import tools.dscode.control.protocol.ControlBridgeBrowserScreenshot; +import tools.dscode.control.protocol.ControlBridgeBrowserScreenshotResult; +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ControlBridgeElementEvidence; +import tools.dscode.control.protocol.ControlBridgeElementInspection; +import tools.dscode.control.protocol.ControlBridgeElementInspectionResult; +import tools.dscode.control.protocol.ControlBridgeError; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshotResult; +import tools.dscode.control.protocol.ControlBridgeScenarioStatus; +import tools.dscode.control.protocol.ControlBridgeServiceCallEvidence; +import tools.dscode.control.protocol.ControlBridgeServiceCallResult; +import tools.dscode.control.protocol.ControlBridgeStatus; +import tools.dscode.control.protocol.ControlBridgeValue; +import tools.dscode.control.protocol.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.coredefinitions.BrowserSteps; import java.lang.reflect.Array; @@ -51,6 +73,8 @@ import java.util.function.Function; import java.util.function.Supplier; +import static tools.dscode.common.mappings.ValueFormatting.MAPPER; + final class ControlBridgeCoordinator implements ControlHookHandler, AutoCloseable { static final int DEFAULT_WAIT_SECONDS = 30; static final int DEFAULT_PAUSE_LEASE_SECONDS = 120; @@ -128,7 +152,7 @@ ControlBridgeStatus status() { int activeCount = lanes.size(); if (selected == null) { return new ControlBridgeStatus( - ControlBridgeRuntime.PROTOCOL_VERSION, + ControlProtocol.CURRENT_VERSION, runtimeId, pid, activeCount, @@ -589,7 +613,10 @@ private ControlBridgeMappingSnapshotResult snapshotSuccess(String mapReference, map.getClass().getName(), dataSources(map), map.getClass() == NodeMap.class, - values + MAPPER.convertValue( + values, + new com.fasterxml.jackson.core.type.TypeReference>() { } + ) ), null, runtime @@ -618,7 +645,7 @@ private ControlBridgeCallResult restoreSnapshot(ControlBridgeMappingSnapshot sna if (!target.getMapType().name().equals(snapshot.mapType())) return unavailable("The live map type no longer matches the captured snapshot.", lane.status(lanes.size())); if (!dataSources(target).equals(snapshot.dataSources())) return unavailable("The live map data sources no longer match the captured snapshot.", lane.status(lanes.size())); - ObjectNode values = snapshot.values().deepCopy(); + ObjectNode values = MAPPER.valueToTree(snapshot.values()); values.remove(NodeMap.MAP_TYPE_KEY); var mapType = target.getMapType(); target.clearValues(); @@ -699,7 +726,7 @@ private ControlBridgeElementInspectionResult elementResult(ControlCallResult enumValue) return enumValue.name(); - if (value instanceof JsonNode) return value; + if (value instanceof JsonNode) return MAPPER.convertValue(value, Object.class); if (path.put(value, Boolean.TRUE) != null) return NOT_JSON_COMPATIBLE; try { if (value instanceof Map map) { @@ -959,7 +1050,7 @@ private void finish() { private ControlBridgeStatus status(int activeCount) { return new ControlBridgeStatus( - ControlBridgeRuntime.PROTOCOL_VERSION, + ControlProtocol.CURRENT_VERSION, runtimeId, pid, activeCount, threadId, scenarioId, scenarioName, stepText, phraseText, lastHook, lastSignature, paused, pauseRequested, capabilities diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeEventRecorder.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeEventRecorder.java index 704b96a1..160fb634 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeEventRecorder.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeEventRecorder.java @@ -7,6 +7,8 @@ import tools.dscode.common.control.ControlEvent; import tools.dscode.common.control.ControlHookHandler; import tools.dscode.common.treeparsing.parsedComponents.Phrase; +import tools.dscode.control.protocol.ControlBridgeEvent; +import tools.dscode.control.protocol.ControlBridgeEventPage; import java.time.Instant; import java.util.ArrayDeque; diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshot.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshot.java index 62d652e3..b63c1853 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshot.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshot.java @@ -4,7 +4,7 @@ import java.util.List; -/** Materialized state for one live NodeMap captured through the Studio bridge. */ +/** Legacy materialized state for one live NodeMap captured through the control bridge. */ public record ControlBridgeMappingSnapshot( int version, String mapReference, diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshotResult.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshotResult.java index 9d304c07..e80a1e00 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshotResult.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeMappingSnapshotResult.java @@ -1,6 +1,7 @@ package tools.dscode.control.bridge; -/** Logical result of capturing one live NodeMap snapshot. */ +/** @deprecated Wire controllers use {@code tools.dscode.control.protocol}. */ +@Deprecated(forRemoval = false) public record ControlBridgeMappingSnapshotResult( String status, ControlBridgeMappingSnapshot snapshot, diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeRuntime.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeRuntime.java index 9eb9647f..f4210939 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeRuntime.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeRuntime.java @@ -8,6 +8,16 @@ import tools.dscode.control.override.StepOverridePatternType; import tools.dscode.control.override.StepOverrideRegistry; import tools.dscode.control.override.StepOverrideRule; +import tools.dscode.control.protocol.ControlBridgeDescriptor; +import tools.dscode.control.protocol.ControlBridgeError; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; +import tools.dscode.control.protocol.ControlBridgeStatus; +import tools.dscode.control.protocol.ControlBridgeStepOverride; +import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; +import tools.dscode.control.protocol.ControlProtocol; + +import static tools.dscode.control.protocol.ControlBridgeRequests.*; +import static tools.dscode.control.protocol.ControlBridgeResponses.*; import java.io.IOException; import java.io.PrintWriter; @@ -28,15 +38,6 @@ import java.util.concurrent.atomic.AtomicBoolean; final class ControlBridgeRuntime implements AutoCloseable { - static final int PROTOCOL_VERSION = 1; - static final List CAPABILITIES = List.of( - "status", "scenarios", "events", "pause", "resume", "execute_step", - "mapping_get", "mapping_put", "mapping_resolve", "mapping_snapshot", "mapping_restore", - "browser_page", "browser_screenshot", - "element_inspect", "service_call", "breakpoints", - "step_overrides", "step_override_compile" - ); - private static final String HOST = "127.0.0.1"; private static final int MAX_REQUEST_BYTES = 1024 * 1024; @@ -77,13 +78,15 @@ static ControlBridgeRuntime start(Path sessionDirectory, String sessionId, Strin try { Files.createDirectories(directory); } catch (IOException failure) { - throw new IllegalStateException("Could not create Pickleball Studio bridge session directory: " + directory, failure); + throw new IllegalStateException("Could not create Pickleball Workbench bridge session directory: " + directory, failure); } String runtimeId = UUID.randomUUID().toString(); long pid = ProcessHandle.current().pid(); ControlBridgeEventRecorder eventRecorder = new ControlBridgeEventRecorder(); - ControlBridgeCoordinator coordinator = new ControlBridgeCoordinator(runtimeId, pid, CAPABILITIES, pauseFirstScenario); + ControlBridgeCoordinator coordinator = new ControlBridgeCoordinator( + runtimeId, pid, ControlProtocol.WORKER_CAPABILITIES, pauseFirstScenario + ); HttpServer server = null; ExecutorService executor = null; @@ -100,8 +103,17 @@ static ControlBridgeRuntime start(Path sessionDirectory, String sessionId, Strin server, executor, new ControlBridgeDescriptor( - PROTOCOL_VERSION, sessionId, runtimeId, pid, HOST, - server.getAddress().getPort(), Instant.now().toString(), CAPABILITIES + ControlProtocol.CURRENT_VERSION, + ControlProtocol.MINIMUM_COMPATIBLE_VERSION, + sessionId, + runtimeId, + pid, + HOST, + server.getAddress().getPort(), + Instant.now().toString(), + runtimeVersion(), + runtimeCodeSource(), + ControlProtocol.WORKER_CAPABILITIES ) ); runtime.registerContexts(); @@ -120,7 +132,7 @@ static ControlBridgeRuntime start(Path sessionDirectory, String sessionId, Strin try { Files.deleteIfExists(descriptorFile); } catch (IOException ignored) { } throw failure instanceof RuntimeException runtimeFailure ? runtimeFailure - : new IllegalStateException("Could not start Pickleball Studio control bridge.", failure); + : new IllegalStateException("Could not start Pickleball Workbench control bridge.", failure); } } @@ -201,10 +213,10 @@ private void registerContexts() { })); server.createContext("/v1/breakpoints/remove", exchange -> handle(exchange, "POST", () -> { BreakpointIdRequest request = readRequired(exchange, BreakpointIdRequest.class); - return Map.of("removed", coordinator.removeBreakpoint(request.breakpointId())); + return new Removal(coordinator.removeBreakpoint(request.breakpointId())); })); server.createContext("/v1/breakpoints/clear", exchange -> handle(exchange, "POST", () -> - Map.of("removed", coordinator.clearBreakpoints()))); + new ClearResult(coordinator.clearBreakpoints()))); server.createContext("/v1/step-overrides", exchange -> handle(exchange, "GET", () -> { String scenarioId = queryParameter(exchange, "scenarioId"); @@ -219,13 +231,13 @@ private void registerContexts() { })); server.createContext("/v1/step-overrides/remove", exchange -> handle(exchange, "POST", () -> { StepOverrideIdRequest request = readRequired(exchange, StepOverrideIdRequest.class); - if (!scenarioActive(request.scenarioId())) return Map.of("removed", false); - return Map.of("removed", StepOverrideRegistry.remove(request.scenarioId(), request.id())); + if (!scenarioActive(request.scenarioId())) return new Removal(false); + return new Removal(StepOverrideRegistry.remove(request.scenarioId(), request.id())); })); server.createContext("/v1/step-overrides/clear", exchange -> handle(exchange, "POST", () -> { StepOverrideScenarioRequest request = readRequired(exchange, StepOverrideScenarioRequest.class); - if (!scenarioActive(request.scenarioId())) return Map.of("removed", 0); - return Map.of("removed", StepOverrideRegistry.clear(request.scenarioId())); + if (!scenarioActive(request.scenarioId())) return new ClearResult(0); + return new ClearResult(StepOverrideRegistry.clear(request.scenarioId())); })); } @@ -401,7 +413,7 @@ private void writeDescriptor() { } } catch (IOException failure) { try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } - throw new IllegalStateException("Could not publish Pickleball Studio bridge descriptor: " + descriptorFile, failure); + throw new IllegalStateException("Could not publish Pickleball Workbench bridge descriptor: " + descriptorFile, failure); } } @@ -421,31 +433,22 @@ private static String safeMessage(Throwable failure) { return failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage(); } + private static String runtimeVersion() { + String version = ControlBridgeRuntime.class.getPackage().getImplementationVersion(); + return version == null || version.isBlank() ? "development" : version; + } + + private static String runtimeCodeSource() { + try { + var source = ControlBridgeRuntime.class.getProtectionDomain().getCodeSource(); + if (source == null || source.getLocation() == null) return "unknown"; + return Path.of(source.getLocation().toURI()).toAbsolutePath().normalize().toString(); + } catch (Exception failure) { + return "unknown"; + } + } + @FunctionalInterface private interface RequestAction { Object run() throws Exception; } - private record PauseRequest(String scenarioId, Integer waitSeconds, Integer leaseSeconds) { } - private record ResumeRequest(String scenarioId) { } - private record ExecuteStepRequest(String scenarioId, String text, String argument, Integer timeoutSeconds) { } - private record MappingGetRequest(String scenarioId, String mapReference, String key, Integer timeoutSeconds) { } - private record MappingPutRequest(String scenarioId, String mapReference, String key, Object value, Integer timeoutSeconds) { } - private record MappingResolveRequest(String scenarioId, String input, Integer timeoutSeconds) { } - private record MappingSnapshotRequest(String scenarioId, String mapReference, Integer timeoutSeconds) { } - private record MappingRestoreRequest(String scenarioId, ControlBridgeMappingSnapshot snapshot, Integer timeoutSeconds) { } - private record BrowserEvidenceRequest(String scenarioId, Integer timeoutSeconds) { } - private record ElementInspectionRequest( - String scenarioId, String category, String text, String operation, - Integer maxElements, Integer timeoutSeconds - ) { } - private record ServiceCallRequest(String scenarioId, String selector, Integer timeoutSeconds) { } - private record BreakpointAddRequest( - String scenarioId, String hook, String signatureContains, String stepContains, - String phraseContains, Boolean oneShot, Integer leaseSeconds - ) { } - private record BreakpointIdRequest(String breakpointId) { } - private record StepOverrideCompileRequest( - String scenarioId, String id, String patternType, String pattern, String source - ) { } - private record StepOverrideIdRequest(String scenarioId, String id) { } - private record StepOverrideScenarioRequest(String scenarioId) { } } diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValueResult.java b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValueResult.java index 6747b041..e148dcd5 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValueResult.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValueResult.java @@ -1,6 +1,7 @@ package tools.dscode.control.bridge; -/** Structured value result for live mapping inspection and mutation. */ +/** @deprecated Wire controllers use {@code tools.dscode.control.protocol}. */ +@Deprecated(forRemoval = false) public record ControlBridgeValueResult( String status, ControlBridgeValue value, diff --git a/pickleball-control-protocol/build.gradle b/pickleball-control-protocol/build.gradle new file mode 100644 index 00000000..96e15097 --- /dev/null +++ b/pickleball-control-protocol/build.gradle @@ -0,0 +1,50 @@ +plugins { + id 'java-library' +} + +group = rootProject.group +version = rootProject.version + +java { + toolchain { languageVersion = JavaLanguageVersion.of(21) } +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +// The protocol is deliberately JDK-only. It is bundled into both the normal +// Pickleball runtime and the controller-only Workbench executable. +def protocolOutput = sourceSets.main.output +def protocolSources = sourceSets.main.allJava +def protocolClasses = tasks.named('classes') + +rootProject.tasks.named('shadowJar').configure { + dependsOn protocolClasses + from(protocolOutput) +} + +rootProject.tasks.named('sourcesJar').configure { + from(protocolSources) +} + +tasks.register('verifyProtocolIsolation') { + group = 'verification' + description = 'Verifies that the shared Workbench wire protocol remains JDK-only.' + + doLast { + def declared = configurations + .findAll { it.name in ['api', 'implementation', 'compileOnly', 'runtimeOnly'] } + .collectMany { it.allDependencies } + if (!declared.isEmpty()) { + throw new GradleException( + 'pickleball-control-protocol must remain dependency-neutral: ' + + declared.collect { "${it.group}:${it.name}:${it.version}" }.sort() + ) + } + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyProtocolIsolation') +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBoundedJsonEvidence.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBoundedJsonEvidence.java new file mode 100644 index 00000000..b79d54e2 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBoundedJsonEvidence.java @@ -0,0 +1,12 @@ +package tools.dscode.control.protocol; + +/** Bounded JSON-compatible evidence without consumer-runtime object types. */ +public record ControlBridgeBoundedJsonEvidence( + Object value, + int utf8Bytes, + boolean truncated +) { + public ControlBridgeBoundedJsonEvidence { + value = ControlBridgeJson.immutableValue(value); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBreakpoint.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBreakpoint.java new file mode 100644 index 00000000..a79d1e27 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBreakpoint.java @@ -0,0 +1,16 @@ +package tools.dscode.control.protocol; + +public record ControlBridgeBreakpoint( + String breakpointId, + String scenarioId, + String hook, + String signatureContains, + String stepContains, + String phraseContains, + boolean oneShot, + int leaseSeconds, + long hitCount, + String lastHitAt, + String lastScenarioId +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPage.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPage.java new file mode 100644 index 00000000..ae4efb6f --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPage.java @@ -0,0 +1,19 @@ +package tools.dscode.control.protocol; + +import java.util.List; + +/** Bounded read-only evidence from the browser already owned by one scenario. */ +public record ControlBridgeBrowserPage( + String url, + String title, + String windowHandle, + List windowHandles, + int windowWidth, + int windowHeight, + String pageSource, + boolean pageSourceTruncated +) { + public ControlBridgeBrowserPage { + windowHandles = windowHandles == null ? List.of() : List.copyOf(windowHandles); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPageResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPageResult.java new file mode 100644 index 00000000..53e2d5dd --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPageResult.java @@ -0,0 +1,10 @@ +package tools.dscode.control.protocol; + +/** Logical result of reading current browser page evidence. */ +public record ControlBridgeBrowserPageResult( + String status, + ControlBridgeBrowserPage page, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshot.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshot.java new file mode 100644 index 00000000..e7a68370 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshot.java @@ -0,0 +1,9 @@ +package tools.dscode.control.protocol; + +/** Bounded PNG evidence captured from the browser already owned by one scenario. */ +public record ControlBridgeBrowserScreenshot( + String mimeType, + int byteSize, + String base64 +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshotResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshotResult.java new file mode 100644 index 00000000..175d724b --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshotResult.java @@ -0,0 +1,10 @@ +package tools.dscode.control.protocol; + +/** Logical result of capturing current browser screenshot evidence. */ +public record ControlBridgeBrowserScreenshotResult( + String status, + ControlBridgeBrowserScreenshot screenshot, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeCallResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeCallResult.java new file mode 100644 index 00000000..46bc5472 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeCallResult.java @@ -0,0 +1,11 @@ + +package tools.dscode.control.protocol; + +public record ControlBridgeCallResult( + String status, + String valueType, + String valueText, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeDescriptor.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeDescriptor.java new file mode 100644 index 00000000..116a7a84 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeDescriptor.java @@ -0,0 +1,22 @@ + +package tools.dscode.control.protocol; + +import java.util.List; + +public record ControlBridgeDescriptor( + int protocolVersion, + int minimumCompatibleProtocolVersion, + String sessionId, + String runtimeId, + long pid, + String host, + int port, + String startedAt, + String runtimeVersion, + String runtimeCodeSource, + List capabilities +) { + public ControlBridgeDescriptor { + capabilities = capabilities == null ? List.of() : List.copyOf(capabilities); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementEvidence.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementEvidence.java new file mode 100644 index 00000000..1638eaf8 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementEvidence.java @@ -0,0 +1,29 @@ +package tools.dscode.control.protocol; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Bounded read-only evidence for one element resolved by the consumer worker. */ +public record ControlBridgeElementEvidence( + int index, + String tagName, + String text, + String value, + boolean displayed, + boolean enabled, + boolean selected, + int x, + int y, + int width, + int height, + Map attributes, + String outerHtml, + boolean outerHtmlTruncated +) { + public ControlBridgeElementEvidence { + attributes = attributes == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspection.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspection.java new file mode 100644 index 00000000..9c0b24f8 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspection.java @@ -0,0 +1,18 @@ +package tools.dscode.control.protocol; + +import java.util.List; + +/** Pickleball-native element-resolution evidence represented only as wire data. */ +public record ControlBridgeElementInspection( + String category, + String text, + String operation, + String resolvedXPath, + int matchCount, + boolean evidenceTruncated, + List elements +) { + public ControlBridgeElementInspection { + elements = elements == null ? List.of() : List.copyOf(elements); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspectionResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspectionResult.java new file mode 100644 index 00000000..71bc7f5c --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspectionResult.java @@ -0,0 +1,9 @@ +package tools.dscode.control.protocol; + +public record ControlBridgeElementInspectionResult( + String status, + ControlBridgeElementInspection inspection, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeError.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeError.java new file mode 100644 index 00000000..60a96630 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeError.java @@ -0,0 +1,9 @@ + +package tools.dscode.control.protocol; + +public record ControlBridgeError( + String type, + String message, + String stackTrace +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEvent.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEvent.java new file mode 100644 index 00000000..bc1d7e7e --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEvent.java @@ -0,0 +1,15 @@ +package tools.dscode.control.protocol; + +/** Immutable bounded snapshot of one semantic Pickleball control hook. */ +public record ControlBridgeEvent( + long sequence, + String timestamp, + long threadId, + String scenarioId, + String scenarioName, + String hook, + String signature, + String stepText, + String phraseText +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEventPage.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEventPage.java new file mode 100644 index 00000000..178b7afe --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEventPage.java @@ -0,0 +1,17 @@ +package tools.dscode.control.protocol; + +import java.util.List; + +/** Cursor page over the bounded semantic event history retained by one consumer runtime. */ +public record ControlBridgeEventPage( + List events, + long nextSequence, + long earliestAvailableSequence, + long latestSequence, + boolean gap, + boolean hasMore +) { + public ControlBridgeEventPage { + events = events == null ? List.of() : List.copyOf(events); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeJson.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeJson.java new file mode 100644 index 00000000..656b5d7d --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeJson.java @@ -0,0 +1,44 @@ +package tools.dscode.control.protocol; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Internal defensive-copy support for JSON-compatible protocol values. */ +final class ControlBridgeJson { + private ControlBridgeJson() { + } + + static Object immutableValue(Object value) { + if (value == null + || value instanceof String + || value instanceof Number + || value instanceof Boolean) { + return value; + } + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, child) -> { + if (!(key instanceof String text)) { + throw new IllegalArgumentException( + "Control protocol JSON object keys must be strings." + ); + } + copy.put(text, immutableValue(child)); + }); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) { + return list.stream().map(ControlBridgeJson::immutableValue).toList(); + } + throw new IllegalArgumentException( + "Control protocol value is not JSON-compatible: " + value.getClass().getName() + ); + } + + @SuppressWarnings("unchecked") + static Map immutableObject(Map value) { + return (Map) immutableValue(value); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshot.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshot.java new file mode 100644 index 00000000..f2896ee4 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshot.java @@ -0,0 +1,22 @@ +package tools.dscode.control.protocol; + +import java.util.List; +import java.util.Map; + +/** Materialized JSON-compatible state for one live NodeMap captured over the wire. */ +public record ControlBridgeMappingSnapshot( + int version, + String mapReference, + String mapType, + String mapClass, + List dataSources, + boolean restorable, + Map values +) { + public static final int CURRENT_VERSION = 1; + + public ControlBridgeMappingSnapshot { + dataSources = dataSources == null ? List.of() : List.copyOf(dataSources); + values = values == null ? null : ControlBridgeJson.immutableObject(values); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshotResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshotResult.java new file mode 100644 index 00000000..86afc609 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshotResult.java @@ -0,0 +1,10 @@ +package tools.dscode.control.protocol; + +/** Logical result of capturing one live NodeMap snapshot. */ +public record ControlBridgeMappingSnapshotResult( + String status, + ControlBridgeMappingSnapshot snapshot, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeRequests.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeRequests.java new file mode 100644 index 00000000..c855447b --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeRequests.java @@ -0,0 +1,53 @@ +package tools.dscode.control.protocol; + +/** Request DTOs for the local versioned controller/worker transport. */ +public final class ControlBridgeRequests { + private ControlBridgeRequests() { + } + + public record PauseRequest(String scenarioId, Integer waitSeconds, Integer leaseSeconds) { } + public record ResumeRequest(String scenarioId) { } + public record ExecuteStepRequest( + String scenarioId, String text, String argument, Integer timeoutSeconds + ) { } + public record MappingGetRequest( + String scenarioId, String mapReference, String key, Integer timeoutSeconds + ) { } + public record MappingPutRequest( + String scenarioId, String mapReference, String key, Object value, Integer timeoutSeconds + ) { } + public record MappingResolveRequest( + String scenarioId, String input, Integer timeoutSeconds + ) { } + public record MappingSnapshotRequest( + String scenarioId, String mapReference, Integer timeoutSeconds + ) { } + public record MappingRestoreRequest( + String scenarioId, ControlBridgeMappingSnapshot snapshot, Integer timeoutSeconds + ) { } + public record BrowserEvidenceRequest(String scenarioId, Integer timeoutSeconds) { } + public record ElementInspectionRequest( + String scenarioId, + String category, + String text, + String operation, + Integer maxElements, + Integer timeoutSeconds + ) { } + public record ServiceCallRequest(String scenarioId, String selector, Integer timeoutSeconds) { } + public record BreakpointAddRequest( + String scenarioId, + String hook, + String signatureContains, + String stepContains, + String phraseContains, + Boolean oneShot, + Integer leaseSeconds + ) { } + public record BreakpointIdRequest(String breakpointId) { } + public record StepOverrideCompileRequest( + String scenarioId, String id, String patternType, String pattern, String source + ) { } + public record StepOverrideIdRequest(String scenarioId, String id) { } + public record StepOverrideScenarioRequest(String scenarioId) { } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeResponses.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeResponses.java new file mode 100644 index 00000000..7896b790 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeResponses.java @@ -0,0 +1,10 @@ +package tools.dscode.control.protocol; + +/** Small mutation response envelopes shared by both sides of the wire. */ +public final class ControlBridgeResponses { + private ControlBridgeResponses() { + } + + public record Removal(boolean removed) { } + public record ClearResult(int removed) { } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeScenarioStatus.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeScenarioStatus.java new file mode 100644 index 00000000..26f0bb28 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeScenarioStatus.java @@ -0,0 +1,15 @@ +package tools.dscode.control.protocol; + +/** One active Pickleball scenario observed by a consumer runtime bridge. */ +public record ControlBridgeScenarioStatus( + long threadId, + String scenarioId, + String scenarioName, + String stepText, + String phraseText, + String lastHook, + String lastSignature, + boolean paused, + boolean pauseRequested +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallEvidence.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallEvidence.java new file mode 100644 index 00000000..8c2648aa --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallEvidence.java @@ -0,0 +1,11 @@ +package tools.dscode.control.protocol; + +/** Structured service-call evidence represented only as bounded wire data. */ +public record ControlBridgeServiceCallEvidence( + String selector, + ControlBridgeBoundedJsonEvidence request, + ControlBridgeBoundedJsonEvidence configuration, + ControlBridgeBoundedJsonEvidence response, + Integer statusCode +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallResult.java new file mode 100644 index 00000000..1358cfea --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallResult.java @@ -0,0 +1,9 @@ +package tools.dscode.control.protocol; + +public record ControlBridgeServiceCallResult( + String status, + ControlBridgeServiceCallEvidence evidence, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStatus.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStatus.java new file mode 100644 index 00000000..47aefaab --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStatus.java @@ -0,0 +1,25 @@ + +package tools.dscode.control.protocol; + +import java.util.List; + +public record ControlBridgeStatus( + int protocolVersion, + String runtimeId, + long pid, + int activeScenarioCount, + Long selectedScenarioThreadId, + String scenarioId, + String scenarioName, + String stepText, + String phraseText, + String lastHook, + String lastSignature, + boolean paused, + boolean pauseRequested, + List capabilities +) { + public ControlBridgeStatus { + capabilities = capabilities == null ? List.of() : List.copyOf(capabilities); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverride.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverride.java new file mode 100644 index 00000000..5f3f7033 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverride.java @@ -0,0 +1,9 @@ +package tools.dscode.control.protocol; + +public record ControlBridgeStepOverride( + String id, + String patternType, + String pattern, + String handlerClass +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverrideResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverrideResult.java new file mode 100644 index 00000000..b35e142b --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverrideResult.java @@ -0,0 +1,9 @@ +package tools.dscode.control.protocol; + +public record ControlBridgeStepOverrideResult( + String status, + ControlBridgeStepOverride override, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java new file mode 100644 index 00000000..e535fc22 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java @@ -0,0 +1,13 @@ +package tools.dscode.control.protocol; + +/** Safe cross-JVM representation of a live Pickleball value. */ +public record ControlBridgeValue( + String type, + boolean jsonCompatible, + Object jsonValue, + String text +) { + public ControlBridgeValue { + jsonValue = ControlBridgeJson.immutableValue(jsonValue); + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java new file mode 100644 index 00000000..be996a3e --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java @@ -0,0 +1,10 @@ +package tools.dscode.control.protocol; + +/** Structured value result for live mapping inspection and mutation. */ +public record ControlBridgeValueResult( + String status, + ControlBridgeValue value, + ControlBridgeError error, + ControlBridgeStatus runtime +) { +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java new file mode 100644 index 00000000..210ed3d6 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java @@ -0,0 +1,33 @@ +package tools.dscode.control.protocol; + +import java.util.List; + +/** Versioned, dependency-neutral constants shared by the controller and consumer worker. */ +public final class ControlProtocol { + public static final int CURRENT_VERSION = 2; + public static final int MINIMUM_COMPATIBLE_VERSION = 2; + + public static final String WORKER_MAIN_CLASS = "tools.dscode.testengine.WorkbenchWorkerMain"; + public static final String WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY = + "pickleball.workbench.testOutputRoot"; + public static final String EMBEDDED_WORKBENCH_RESOURCE = + "META-INF/pickleball/workbench/pickleball-workbench.jar"; + + public static final String SESSION_DIRECTORY_ENV = "PKB_CONTROL_BRIDGE_SESSION_DIR"; + public static final String SESSION_ID_ENV = "PKB_CONTROL_BRIDGE_SESSION_ID"; + public static final String SESSION_TOKEN_ENV = "PKB_CONTROL_BRIDGE_TOKEN"; + public static final String PAUSE_FIRST_SCENARIO_ENV = + "PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO"; + + public static final List WORKER_CAPABILITIES = List.of( + "status", "scenarios", "events", "pause", "resume", "execute_step", + "mapping_get", "mapping_put", "mapping_resolve", "mapping_snapshot", "mapping_restore", + "browser_page", "browser_screenshot", "element_inspect", "service_call", "breakpoints", + "step_overrides", "step_override_compile" + ); + + public static final List CONTROLLER_REQUIRED_CAPABILITIES = WORKER_CAPABILITIES; + + private ControlProtocol() { + } +} diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 6774d0de..c81ba944 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -4,19 +4,23 @@ Root `AGENTS.md` remains authoritative. Read it and `docs/agent/feature-map.md` ## Module role -`pickleball-workbench` is the separate executable companion for interactive Pickleball tooling. The dependency direction is strictly: +`pickleball-workbench` is the external controller/control plane for interactive Pickleball tooling. It is not a Pickleball runtime. The dependency and distribution graph is strictly: ```text -pickleball-workbench -> pickleball +pickleball core/worker --------> pickleball-control-protocol +pickleball-workbench ----------> pickleball-control-protocol +published pickleball JAR ------> opaque completed Workbench JAR bytes ``` -The normal `tools.dscode:pickleball` artifact must never depend on or embed Workbench classes or Workbench-only dependencies. +The final line is an assembly input, not a Java/runtime dependency. Pickleball may contain Workbench for delivery; Workbench must not contain Pickleball for execution. ## Build boundary -Workbench must compile and run against the repository's published-equivalent shaded/woven Pickleball artifact through the dedicated root configuration. Do not replace that boundary with a naïve `implementation project(':')`, and do not add a dependency on the unpublished `pickleball-control-api` module. +Workbench must compile and run without resolving the root project, `tools.dscode:pickleball`, a published-equivalent/shaded root configuration, or the behavioral `pickleball-control-api`. Its only project dependency is the JDK-only `pickleball-control-protocol` module. Never restore `implementation project(':')`, `pickleballPublishedElements`, a Pickleball Maven dependency, or core shading to fix compilation. -Workbench-only dependencies, including the MCP SDK, belong only on the Workbench classpath. The MCP adapter uses the non-Spring MCP Java SDK core plus its Jackson 2 adapter; do not replace them with the convenience/Jackson 3 artifact or Spring transports without a new architecture decision. Do not move consumer-worker runtime semantics into the controller merely to simplify dependencies. +The protocol module owns only stable wire DTOs, request/response envelopes, transport constants, capability lists, and explicit version negotiation. It owns no bridge server, bootstrap, mapping logic, Cucumber/Selenium/service behavior, filesystem synchronization, UI, or MCP behavior. When a new runtime capability is required, implement it in core/worker and expose neutral wire data; do not move the behavior into Workbench or protocol. + +Workbench-only dependencies, including Jackson and the MCP SDK, belong only on the Workbench classpath. The executable and every nested JAR/service descriptor must remain free of Pickleball core, `pickleball-control-api`, bridge-server/worker implementation, consumer classes, Cucumber, Selenium, and REST-assured. The MCP adapter uses the non-Spring MCP Java SDK core plus its Jackson 2 adapter; do not replace them with the convenience/Jackson 3 artifact or Spring transports without a new architecture decision. ## Runtime ownership @@ -24,7 +28,7 @@ The Workbench controller owns synchronization, worker process/session lifecycle, `WorkbenchServices` is the shared plain-Java adapter boundary. `WorkbenchController` composes synchronization and `WorkbenchLiveSession`; MCP and Swing must delegate to that service surface instead of implementing their own worker ownership, bridge calls, Mapping semantics, Step Override behavior, or scenario retry rules. -The Workbench bridge client uses the public `tools.dscode.control.bridge.*` DTOs from the normal Pickleball artifact. Do not create a second controller-side model of Pickleball execution semantics. +The Workbench bridge client uses only `tools.dscode.control.protocol.*`. Worker-side `ControlBridgeRuntime`, `ControlBridgeCoordinator`, bootstrap, adapters, step compilation, mappings, and execution semantics stay in Pickleball. Workbench may hold the worker entry-point class name as `ControlProtocol.WORKER_MAIN_CLASS`; it must never import or load that class. The canonical consumer-worker bridge environment is: @@ -110,7 +114,7 @@ Workbench synchronization is build-tool-assisted, not a replacement build system `.pickleball/workbench/base/classes` is synchronization provenance/reset state and must never be on a worker runtime classpath. `.pickleball/workbench/live/classes` is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. -The controller owns one interactive worker per selected project by default. Workers launch directly with Java from the existing Workbench snapshot, use the Pickleball-side `WorkbenchWorkerMain` bootstrap, and set `pickleball.workbench.testOutputRoot` so `DynamicSuiteBootstrap` intentionally scans the merged live root instead of relying on Maven/Gradle output suffixes. +The controller owns one interactive worker per selected project by default. Workers launch directly with Java from the existing Workbench snapshot, use the Pickleball-side worker class-name contract, and set the protocol-owned `pickleball.workbench.testOutputRoot` property so core intentionally scans the merged live root instead of relying on Maven/Gradle output suffixes. The worker PID must differ from the controller PID; its reported Pickleball code source must be exactly one captured consumer classpath entry; its version must match the synchronized manifest; and its classpath must exclude the Workbench controller artifact. Incompatible protocol/capability/origin checks fail clearly and never fall back to a bundled runtime. Interactive workers use a session-private anchor feature and the neutral `PKB_CONTROL_BRIDGE_*` environment contract. The anchor body must be a guaranteed no-op core step. The bridge's pause-first behavior stops first at `SCENARIO_START`, which occurs before `CurrentScenarioState.startScenarioRun()` finishes Pickleball scenario initialization; Workbench treats that pause only as a bootstrap rendezvous. Before returning an interactive worker, the controller installs a one-shot `BEFORE_STEP` breakpoint filtered to the anchor marker step `---pickleball-workbench-anchor`, resumes the bootstrap pause, and lets the root scenario step initialize normal logging/runtime state. It returns only after the marker itself is paused immediately before execution. Live controller operations must run only after that promotion. Pause leases remain finite; the controller renews the owned anchor lease while active. Graceful stop cancels renewal, resumes the anchor so normal lifecycle hooks can finish, waits a bounded period, then terminates and only force-kills as a final fallback. Restart must reuse the existing manifest/classpath, require the previous worker to have stopped cleanly, and must not run Maven/Gradle. @@ -118,7 +122,7 @@ Worker JVM system-property overrides are explicit controller inputs. The default ## Live runtime operations -`WorkbenchLiveSession` is the controller-side scenario-bound facade for operations on the persistent paused worker. It delegates to `ControlBridgeClient` and the published Pickleball bridge DTOs; it must not reimplement Gherkin matching, mappings, browser behavior, service calls, semantic hook behavior, or Step Override matching/compilation. +`WorkbenchLiveSession` is the controller-side scenario-bound facade for operations on the persistent paused worker. It delegates to `ControlBridgeClient` and neutral protocol DTOs; it must not reimplement Gherkin matching, mappings, browser behavior, service calls, semantic hook behavior, or Step Override matching/compilation. Each live operation resolves the currently owned paused scenario, performs the bridge call for that scenario, and verifies afterward that the same process id, bridge runtime id, and scenario id remain active and paused. Normal live operations must not invoke Maven/Gradle, resynchronize the project, or restart the worker. @@ -127,3 +131,9 @@ Each live operation resolves the currently owned paused scenario, performs the b With an active override, raw Gherkin may be override-only and need not match ordinary consumer glue. If no override matches, normal Cucumber glue matching remains authoritative. Removing or clearing an override restores that fallback immediately without rebuilding or restarting the worker. `live-check` is the direct acceptance probe for this contract. Against the Maven example consumer it executes consumer and Pickleball Gherkin, mutates/resolves the live mapping, performs the existing `%health-full-url` service call, reads browser evidence, compiles and replaces one generated Step Override, executes override-only Gherkin, removes the override, verifies fallback behavior, confirms one PID/runtime/scenario was retained, then resumes and requires a clean exit. + +## Isolation verification and scenario scope + +Keep `verifyWorkbenchArtifact`, `verifyWorkbenchRuntimeBoundary`, `verifyWorkbenchPublishedDependencyContract`, root `verifyEmbeddedWorkbench`, and root `verifyStrictControllerIsolation` aligned with this contract. The checks must inspect resolved provenance, top-level and nested JAR entries, service providers, exact opaque payload count/bytes, controller/runtime class visibility, PIDs, classpaths, runtime origin, protocol version, and capabilities. Do not weaken denylist checks when packages move; update them and retain provenance checks. + +For Workbench/control-bridge changes, run only affected focused Cucumber tags—normally `@control-bridge` and/or `@step-override-bridge`—with `-Dpkb_runvars.pkb_parallel=80` where practical. Never use `@all` for this migration or as a substitute for targeted validation. Record commands honestly. diff --git a/pickleball-workbench/build.gradle b/pickleball-workbench/build.gradle index f4198d5c..0cb79bfe 100644 --- a/pickleball-workbench/build.gradle +++ b/pickleball-workbench/build.gradle @@ -22,9 +22,13 @@ java { def mcpSdkVersion = '2.0.0' dependencies { - // Deliberately target the root project's published-equivalent shaded/woven - // variant. Do not replace this with a naïve implementation project(':'). - implementation project(path: ':', configuration: 'pickleballPublishedElements') + // Workbench shares only the JDK-only wire contract with the consumer worker. + // It must never compile or run against the root Pickleball runtime. + implementation project(':pickleball-control-protocol') + + // Controller-side JSON transport. This is declared directly instead of + // inheriting it through Pickleball core. + implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.0' // Keep MCP Workbench-only and non-Spring. Jackson 2 matches Pickleball's // existing ObjectMapper usage without pulling the SDK convenience/Jackson 3 path. @@ -132,8 +136,8 @@ publishing { } } - // The executable shades its implementation libraries. Its only - // published dependency contract is the normal Pickleball artifact. + // The executable shades all controller dependencies. It has no + // published dependency on Pickleball core or the protocol module. pom.withXml { def pomNode = asNode() def depsNodeList = pomNode.get('dependencies') as groovy.util.NodeList @@ -141,12 +145,6 @@ publishing { ? (groovy.util.Node) depsNodeList[0] : pomNode.appendNode('dependencies') depsNode.children().clear() - - def dependency = depsNode.appendNode('dependency') - dependency.appendNode('groupId', rootProject.group.toString()) - dependency.appendNode('artifactId', 'pickleball') - dependency.appendNode('version', rootProject.version.toString()) - dependency.appendNode('scope', 'compile') } } } @@ -192,15 +190,58 @@ tasks.named('test') { systemProperty 'pickleball.workbench.test.jar', workbenchJar.get().asFile.absolutePath } } -def rootPickleballJar = rootProject.tasks.named('shadowJar').flatMap { it.archiveFile } - tasks.register('verifyWorkbenchArtifact') { group = 'verification' - description = 'Verifies the Workbench executable JAR and one-way packaging boundary.' - dependsOn tasks.shadowJar, rootProject.tasks.named('shadowJar') + description = 'Verifies the Workbench executable contains controller code and no Pickleball runtime.' + dependsOn tasks.shadowJar doLast { def jarFile = workbenchJar.get().asFile + def forbiddenPrefixes = [ + 'tools/dscode/testengine/', + 'tools/dscode/common/', + 'tools/dscode/coredefinitions/', + 'tools/dscode/pickleruntime/', + 'tools/dscode/control/api/', + 'tools/dscode/control/bridge/', + 'tools/dscode/control/override/', + 'io/cucumber/', + 'org/openqa/selenium/', + 'io/restassured/' + ] + def forbiddenProviderPrefixes = forbiddenPrefixes.collect { prefix -> + prefix.replace('/', '.') + } + def forbidden = [] + + def scanNestedJar + scanNestedJar = { byte[] bytes, String source -> + new java.util.zip.ZipInputStream(new ByteArrayInputStream(bytes)).withCloseable { nested -> + java.util.zip.ZipEntry entry + while ((entry = nested.nextEntry) != null) { + String name = entry.name + if (forbiddenPrefixes.any { name.startsWith(it) }) { + forbidden.add("${source}!/${name}") + } + if (!entry.directory && name.endsWith('.jar')) { + scanNestedJar(nested.readAllBytes(), "${source}!/${name}") + } else if (!entry.directory && name.startsWith('META-INF/services/')) { + String providers = new String( + nested.readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8 + ) + if (providers.readLines().collect { line -> + line.replaceFirst(/#.*/, '').trim() + }.any { provider -> + forbiddenProviderPrefixes.any { provider.startsWith(it) } + }) { + forbidden.add("${source}!/${name} -> ${providers.trim()}") + } + } + } + } + } + new java.util.jar.JarFile(jarFile).withCloseable { jar -> def mainClass = jar.manifest?.mainAttributes?.getValue('Main-Class') if (mainClass != application.mainClass.get()) { @@ -209,95 +250,114 @@ tasks.register('verifyWorkbenchArtifact') { if (jar.getEntry('tools/dscode/workbench/WorkbenchApplication.class') == null) { throw new GradleException('Workbench executable is missing WorkbenchApplication.class') } + if (jar.getEntry('tools/dscode/workbench/WorkbenchRuntimeBoundary.class') == null) { + throw new GradleException('Workbench executable is missing its runtime isolation guard') + } + if (jar.getEntry('tools/dscode/workbench/WorkbenchController.class') == null) { + throw new GradleException('Workbench executable is missing its shared controller service') + } + if (jar.getEntry('tools/dscode/workbench/bridge/ControlBridgeClient.class') == null) { + throw new GradleException('Workbench executable is missing its neutral protocol client') + } + if (jar.getEntry('tools/dscode/workbench/ui/WorkbenchUi.class') == null) { + throw new GradleException('Workbench executable is missing its GUI adapter') + } if (jar.getEntry('tools/dscode/workbench/mcp/WorkbenchMcpServer.class') == null) { throw new GradleException('Workbench executable is missing WorkbenchMcpServer.class') } - } - - new java.util.jar.JarFile(rootPickleballJar.get().asFile).withCloseable { jar -> - def leaked = java.util.Collections.list(jar.entries()).findAll { entry -> - entry.name.startsWith('tools/dscode/workbench/') || - entry.name.startsWith('META-INF/pickleball/workbench/') || - entry.name.startsWith('io/modelcontextprotocol/') + if (jar.getEntry('tools/dscode/control/protocol/ControlProtocol.class') == null) { + throw new GradleException('Workbench executable is missing the neutral control protocol') } - if (!leaked.isEmpty()) { - throw new GradleException( - 'Workbench/MCP content leaked into the normal Pickleball artifact: ' + - leaked.collect { it.name }.take(10) - ) + + java.util.Collections.list(jar.entries()).each { entry -> + String name = entry.name + if (forbiddenPrefixes.any { name.startsWith(it) }) { + forbidden.add(name) + } + if (!entry.directory && name.endsWith('.jar')) { + scanNestedJar(jar.getInputStream(entry).readAllBytes(), name) + } else if (!entry.directory && name.startsWith('META-INF/services/')) { + String providers = jar.getInputStream(entry).getText('UTF-8') + if (providers.readLines().collect { line -> + line.replaceFirst(/#.*/, '').trim() + }.any { provider -> + forbiddenProviderPrefixes.any { provider.startsWith(it) } + }) { + forbidden.add("${name} -> ${providers.trim()}") + } + } } } + if (!forbidden.isEmpty()) { + throw new GradleException( + 'Pickleball runtime content leaked into the controller-only Workbench JAR: ' + + forbidden.take(20) + ) + } } } tasks.register('verifyWorkbenchRuntimeBoundary') { group = 'verification' - description = 'Verifies Workbench resolves the shaded Pickleball artifact without unpublished/control or unwoven Cucumber variants.' - dependsOn rootProject.tasks.named('shadowJar') + description = 'Verifies Workbench compile/runtime graphs resolve only the neutral protocol and controller libraries.' + dependsOn ':pickleball-control-protocol:verifyProtocolIsolation' doLast { - def artifacts = configurations.runtimeClasspath.incoming.artifacts.artifacts - def files = artifacts.collect { it.file.canonicalFile } as Set - def expectedPickleball = rootPickleballJar.get().asFile.canonicalFile - if (!files.contains(expectedPickleball)) { - throw new GradleException( - "Workbench runtime does not contain the published-equivalent Pickleball JAR: ${expectedPickleball}" - ) - } - - def forbiddenModules = [ - 'io.cucumber:cucumber-core', - 'io.cucumber:cucumber-gherkin', - 'io.cucumber:cucumber-gherkin-messages', - 'io.cucumber:cucumber-java', - 'io.cucumber:cucumber-plugin', - 'io.cucumber:messages', - 'io.cucumber:gherkin' - ] as Set - - def leakedModules = artifacts.findAll { artifact -> - def component = artifact.id.componentIdentifier - component instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier && - forbiddenModules.contains("${component.group}:${component.module}") - }.collect { artifact -> - def component = artifact.id.componentIdentifier - "${component.group}:${component.module}:${component.version}" - }.sort() - if (!leakedModules.isEmpty()) { - throw new GradleException( - 'Workbench runtime resolved unwoven Cucumber modules outside pickleball.jar: ' + leakedModules - ) - } + ['compileClasspath', 'runtimeClasspath'].each { configurationName -> + def artifacts = configurations.named(configurationName).get() + .incoming.artifacts.artifacts + def projectDependencies = artifacts.findAll { artifact -> + def component = artifact.id.componentIdentifier + component instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier + }.collect { artifact -> + def component = artifact.id.componentIdentifier + component.projectPath + }.sort() + if (projectDependencies != [':pickleball-control-protocol']) { + throw new GradleException( + "Workbench ${configurationName} project dependencies must be exactly " + + ':pickleball-control-protocol: ' + projectDependencies + ) + } - def unpublishedControl = artifacts.findAll { artifact -> - def component = artifact.id.componentIdentifier - component instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier && - component.projectPath == ':pickleball-control-api' - } - if (!unpublishedControl.isEmpty()) { - throw new GradleException('Workbench runtime depends on unpublished :pickleball-control-api') - } + def forbiddenModules = artifacts.findAll { artifact -> + def component = artifact.id.componentIdentifier + if (!(component instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier)) { + return false + } + component.group == 'io.cucumber' || + component.group == 'org.seleniumhq.selenium' || + component.group == 'io.rest-assured' || + (component.group == 'tools.dscode' && component.module == 'pickleball') || + (component.group == 'io.modelcontextprotocol.sdk' && + (component.module == 'mcp' || component.module == 'mcp-json-jackson3')) + }.collect { artifact -> + def component = artifact.id.componentIdentifier + "${component.group}:${component.module}:${component.version}" + }.sort() + if (!forbiddenModules.isEmpty()) { + throw new GradleException( + "Workbench ${configurationName} resolved forbidden execution-plane modules: " + + forbiddenModules + ) + } - def forbiddenMcpModules = artifacts.findAll { artifact -> - def component = artifact.id.componentIdentifier - component instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier && - component.group == 'io.modelcontextprotocol.sdk' && - (component.module == 'mcp' || component.module == 'mcp-json-jackson3') - }.collect { artifact -> - def component = artifact.id.componentIdentifier - "${component.group}:${component.module}:${component.version}" - }.sort() - if (!forbiddenMcpModules.isEmpty()) { - throw new GradleException( - 'Workbench MCP resolved the convenience/Jackson 3 path: ' + forbiddenMcpModules - ) + def forbiddenFiles = artifacts.collect { it.file.name }.findAll { name -> + name ==~ /pickleball-\d.*\.jar/ + } + if (!forbiddenFiles.isEmpty()) { + throw new GradleException( + "Workbench ${configurationName} resolved a Pickleball core JAR: " + + forbiddenFiles + ) + } } } } tasks.register('verifyWorkbenchPublishedDependencyContract') { group = 'verification' - description = 'Verifies the Workbench POM declares only tools.dscode:pickleball.' + description = 'Verifies the self-contained Workbench POM declares no Pickleball dependency.' dependsOn tasks.named('generatePomFileForMavenPublication') doLast { @@ -320,12 +380,7 @@ tasks.register('verifyWorkbenchPublishedDependencyContract') { xpath.evaluate('scope', dependency) ] } - def expected = [[ - rootProject.group.toString(), - 'pickleball', - rootProject.version.toString(), - 'compile' - ]] + def expected = [] if (dependencies != expected) { throw new GradleException("Unexpected Workbench published dependencies: ${dependencies}; expected ${expected}") } @@ -509,7 +564,6 @@ tasks.register('reportWorkbenchMcpImpact') { } tasks.named('test') { - dependsOn rootProject.tasks.named('verifyPickleballPublishedElements') finalizedBy( tasks.named('verifyWorkbenchArtifact'), tasks.named('verifyWorkbenchRuntimeBoundary'), diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java index d34b5d04..7a3a2d6b 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java @@ -1,10 +1,10 @@ package tools.dscode.workbench; -import tools.dscode.control.bridge.ControlBridgeBrowserPageResult; -import tools.dscode.control.bridge.ControlBridgeCallResult; -import tools.dscode.control.bridge.ControlBridgeServiceCallResult; -import tools.dscode.control.bridge.ControlBridgeStepOverrideResult; -import tools.dscode.control.bridge.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlBridgeBrowserPageResult; +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ControlBridgeServiceCallResult; +import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; +import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.workbench.mcp.WorkbenchMcpServer; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; @@ -29,6 +29,14 @@ private WorkbenchApplication() { } public static void main(String[] args) { + try { + WorkbenchRuntimeBoundary.verify(); + } catch (IllegalStateException isolationFailure) { + System.err.println("Workbench controller isolation failed: " + isolationFailure.getMessage()); + System.exit(1); + return; + } + if (args.length > 0 && "mcp".equals(args[0])) { int exitCode = runMcpProcess(args, System.out, System.err); if (exitCode != 0) System.exit(exitCode); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index 9ede6209..5edff3d8 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -1,6 +1,6 @@ package tools.dscode.workbench; -import tools.dscode.control.bridge.*; +import tools.dscode.control.protocol.*; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; import tools.dscode.workbench.worker.WorkbenchLiveSession; diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java new file mode 100644 index 00000000..91dc0d9e --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java @@ -0,0 +1,64 @@ +package tools.dscode.workbench; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** Fail-fast proof that the controller process cannot see Pickleball execution classes. */ +final class WorkbenchRuntimeBoundary { + private static final List FORBIDDEN_CLASSES = List.of( + "tools.dscode.testengine.WorkbenchWorkerMain", + "tools.dscode.common.control.ControlRuntime", + "tools.dscode.coredefinitions.GeneralSteps", + "tools.dscode.control.api.DynamicControl", + "tools.dscode.control.bridge.ControlBridgeBootstrap" + ); + + private WorkbenchRuntimeBoundary() { + } + + static void verify() { + ClassLoader controllerLoader = WorkbenchRuntimeBoundary.class.getClassLoader(); + List visible = new ArrayList<>(); + for (String className : FORBIDDEN_CLASSES) { + try { + Class.forName(className, false, controllerLoader); + visible.add(className); + } catch (ClassNotFoundException expected) { + // Controller-only classpath: the consumer worker owns these classes. + } catch (LinkageError failure) { + visible.add(className + " (linkage failure: " + failure.getClass().getSimpleName() + ")"); + } + } + if (!visible.isEmpty()) { + throw new IllegalStateException( + "Pickleball execution classes are visible in the Workbench JVM: " + visible + ); + } + + List coreEntries = List.of( + System.getProperty("java.class.path", "").split( + java.util.regex.Pattern.quote(File.pathSeparator) + ) + ).stream() + .filter(entry -> !entry.isBlank()) + .map(Path::of) + .map(path -> path.getFileName() == null ? path.toString() : path.getFileName().toString()) + .filter(WorkbenchRuntimeBoundary::looksLikePickleballCoreJar) + .toList(); + if (!coreEntries.isEmpty()) { + throw new IllegalStateException( + "Pickleball core JARs are present on the Workbench process classpath: " + coreEntries + ); + } + } + + private static boolean looksLikePickleballCoreJar(String fileName) { + String lower = fileName.toLowerCase(Locale.ROOT); + return lower.matches("pickleball-[0-9].*\\.jar") + && !lower.startsWith("pickleball-workbench-") + && !lower.startsWith("pickleball-control-protocol-"); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java index 9a4c3de1..9aa44874 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -1,17 +1,17 @@ package tools.dscode.workbench; -import tools.dscode.control.bridge.ControlBridgeBreakpoint; -import tools.dscode.control.bridge.ControlBridgeBrowserPageResult; -import tools.dscode.control.bridge.ControlBridgeBrowserScreenshotResult; -import tools.dscode.control.bridge.ControlBridgeCallResult; -import tools.dscode.control.bridge.ControlBridgeElementInspectionResult; -import tools.dscode.control.bridge.ControlBridgeEventPage; -import tools.dscode.control.bridge.ControlBridgeMappingSnapshot; -import tools.dscode.control.bridge.ControlBridgeMappingSnapshotResult; -import tools.dscode.control.bridge.ControlBridgeServiceCallResult; -import tools.dscode.control.bridge.ControlBridgeStepOverride; -import tools.dscode.control.bridge.ControlBridgeStepOverrideResult; -import tools.dscode.control.bridge.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlBridgeBreakpoint; +import tools.dscode.control.protocol.ControlBridgeBrowserPageResult; +import tools.dscode.control.protocol.ControlBridgeBrowserScreenshotResult; +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ControlBridgeElementInspectionResult; +import tools.dscode.control.protocol.ControlBridgeEventPage; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshotResult; +import tools.dscode.control.protocol.ControlBridgeServiceCallResult; +import tools.dscode.control.protocol.ControlBridgeStepOverride; +import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; +import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java index b06267bc..67880ddc 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java @@ -1,7 +1,10 @@ package tools.dscode.workbench.bridge; import com.fasterxml.jackson.databind.ObjectMapper; -import tools.dscode.control.bridge.*; +import tools.dscode.control.protocol.*; + +import static tools.dscode.control.protocol.ControlBridgeRequests.*; +import static tools.dscode.control.protocol.ControlBridgeResponses.*; import java.io.IOException; import java.net.URI; @@ -19,13 +22,12 @@ /** * Workbench-side HTTP client for the consumer-hosted Pickleball control bridge. * - *

The client uses the bridge DTOs published inside {@code tools.dscode:pickleball}; - * it does not duplicate Pickleball runtime semantics in the controller.

+ *

The client depends only on the neutral control-protocol DTOs. Pickleball + * runtime semantics remain exclusively in the consumer worker.

*/ public final class ControlBridgeClient { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); - private static final int PROTOCOL_VERSION = 1; private final ObjectMapper json; private final HttpClient http; @@ -47,7 +49,7 @@ private ControlBridgeClient( ObjectMapper json, HttpClient http ) { - this.descriptor = descriptor; + this.descriptor = validateDescriptor(descriptor); this.token = token; this.json = json; this.http = http; @@ -362,17 +364,50 @@ private T request( } private URI uri(String path) { + return URI.create("http://" + descriptor.host() + ":" + descriptor.port() + path); + } + + private static ControlBridgeDescriptor validateDescriptor(ControlBridgeDescriptor descriptor) { + Objects.requireNonNull(descriptor, "descriptor"); if (!"127.0.0.1".equals(descriptor.host())) { throw new IllegalArgumentException( "Control bridge descriptor is not loopback-bound: " + descriptor.host() ); } - if (descriptor.protocolVersion() != PROTOCOL_VERSION) { + if (descriptor.port() <= 0 || descriptor.port() > 65_535) { throw new IllegalArgumentException( - "Unsupported control bridge protocol: " + descriptor.protocolVersion() + "Control bridge descriptor has an invalid loopback port: " + descriptor.port() ); } - return URI.create("http://" + descriptor.host() + ":" + descriptor.port() + path); + if (descriptor.sessionId() == null || descriptor.sessionId().isBlank() + || descriptor.runtimeId() == null || descriptor.runtimeId().isBlank()) { + throw new IllegalArgumentException( + "Control bridge descriptor must identify its session and runtime." + ); + } + boolean validWorkerRange = descriptor.minimumCompatibleProtocolVersion() > 0 + && descriptor.protocolVersion() >= descriptor.minimumCompatibleProtocolVersion(); + boolean compatible = validWorkerRange + && descriptor.protocolVersion() >= ControlProtocol.MINIMUM_COMPATIBLE_VERSION + && descriptor.minimumCompatibleProtocolVersion() <= ControlProtocol.CURRENT_VERSION; + if (!compatible) { + throw new IllegalArgumentException( + "Incompatible control bridge protocol: worker=" + descriptor.protocolVersion() + + " (minimum " + descriptor.minimumCompatibleProtocolVersion() + ")" + + ", controller=" + ControlProtocol.CURRENT_VERSION + + " (minimum " + ControlProtocol.MINIMUM_COMPATIBLE_VERSION + ")." + ); + } + + List missing = ControlProtocol.CONTROLLER_REQUIRED_CAPABILITIES.stream() + .filter(capability -> !descriptor.capabilities().contains(capability)) + .toList(); + if (!missing.isEmpty()) { + throw new IllegalArgumentException( + "Consumer worker is missing required Workbench capabilities: " + missing + ); + } + return descriptor; } private static int commandTimeout(Integer timeoutSeconds) { @@ -398,44 +433,4 @@ private static String requireText(String value, String name) { return value.trim(); } - private record PauseRequest(String scenarioId, Integer waitSeconds, Integer leaseSeconds) {} - private record ResumeRequest(String scenarioId) {} - private record ExecuteStepRequest( - String scenarioId, String text, String argument, Integer timeoutSeconds - ) {} - private record MappingGetRequest( - String scenarioId, String mapReference, String key, Integer timeoutSeconds - ) {} - private record MappingPutRequest( - String scenarioId, String mapReference, String key, Object value, Integer timeoutSeconds - ) {} - private record MappingResolveRequest( - String scenarioId, String input, Integer timeoutSeconds - ) {} - private record MappingSnapshotRequest( - String scenarioId, String mapReference, Integer timeoutSeconds - ) {} - private record MappingRestoreRequest( - String scenarioId, ControlBridgeMappingSnapshot snapshot, Integer timeoutSeconds - ) {} - private record BrowserEvidenceRequest(String scenarioId, Integer timeoutSeconds) {} - private record ElementInspectionRequest( - String scenarioId, String category, String text, String operation, - Integer maxElements, Integer timeoutSeconds - ) {} - private record ServiceCallRequest( - String scenarioId, String selector, Integer timeoutSeconds - ) {} - private record BreakpointAddRequest( - String scenarioId, String hook, String signatureContains, String stepContains, - String phraseContains, Boolean oneShot, Integer leaseSeconds - ) {} - private record BreakpointIdRequest(String breakpointId) {} - private record StepOverrideCompileRequest( - String scenarioId, String id, String patternType, String pattern, String source - ) {} - private record StepOverrideIdRequest(String scenarioId, String id) {} - private record StepOverrideScenarioRequest(String scenarioId) {} - private record Removal(boolean removed) {} - private record ClearResult(int removed) {} } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index 8b5423e2..e928818e 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; -import tools.dscode.control.bridge.ControlBridgeMappingSnapshot; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; import tools.dscode.workbench.WorkbenchServices; import java.util.ArrayList; diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java index 04a49871..dc71e758 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java @@ -1,22 +1,22 @@ package tools.dscode.workbench.ui; -import tools.dscode.control.api.BoundedJsonEvidence; -import tools.dscode.control.api.ServiceCallEvidence; -import tools.dscode.control.bridge.ControlBridgeBreakpoint; -import tools.dscode.control.bridge.ControlBridgeBrowserPage; -import tools.dscode.control.bridge.ControlBridgeBrowserPageResult; -import tools.dscode.control.bridge.ControlBridgeBrowserScreenshot; -import tools.dscode.control.bridge.ControlBridgeBrowserScreenshotResult; -import tools.dscode.control.bridge.ControlBridgeCallResult; -import tools.dscode.control.bridge.ControlBridgeError; -import tools.dscode.control.bridge.ControlBridgeEvent; -import tools.dscode.control.bridge.ControlBridgeEventPage; -import tools.dscode.control.bridge.ControlBridgeServiceCallResult; -import tools.dscode.control.bridge.ControlBridgeStatus; -import tools.dscode.control.bridge.ControlBridgeStepOverride; -import tools.dscode.control.bridge.ControlBridgeStepOverrideResult; -import tools.dscode.control.bridge.ControlBridgeValue; -import tools.dscode.control.bridge.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlBridgeBoundedJsonEvidence; +import tools.dscode.control.protocol.ControlBridgeBreakpoint; +import tools.dscode.control.protocol.ControlBridgeBrowserPage; +import tools.dscode.control.protocol.ControlBridgeBrowserPageResult; +import tools.dscode.control.protocol.ControlBridgeBrowserScreenshot; +import tools.dscode.control.protocol.ControlBridgeBrowserScreenshotResult; +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ControlBridgeError; +import tools.dscode.control.protocol.ControlBridgeEvent; +import tools.dscode.control.protocol.ControlBridgeEventPage; +import tools.dscode.control.protocol.ControlBridgeServiceCallEvidence; +import tools.dscode.control.protocol.ControlBridgeServiceCallResult; +import tools.dscode.control.protocol.ControlBridgeStatus; +import tools.dscode.control.protocol.ControlBridgeStepOverride; +import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; +import tools.dscode.control.protocol.ControlBridgeValue; +import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.workbench.WorkbenchServices; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -281,7 +281,7 @@ private static String renderScreenshotResult(ControlBridgeBrowserScreenshotResul private static String renderServiceCallResult(ControlBridgeServiceCallResult result) { StringBuilder text = new StringBuilder("Status: ").append(result.status()); - ServiceCallEvidence evidence = result.evidence(); + ControlBridgeServiceCallEvidence evidence = result.evidence(); if (evidence != null) { text.append("\nSelector: ").append(evidence.selector()); text.append("\nHTTP status: ").append(evidence.statusCode()); @@ -294,7 +294,11 @@ private static String renderServiceCallResult(ControlBridgeServiceCallResult res return text.toString(); } - private static void appendJsonEvidence(StringBuilder text, String label, BoundedJsonEvidence evidence) { + private static void appendJsonEvidence( + StringBuilder text, + String label, + ControlBridgeBoundedJsonEvidence evidence + ) { if (evidence == null) return; text.append("\n").append(label); if (evidence.truncated()) text.append(" (truncated)"); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java index 0cca8b72..20695111 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java @@ -1,6 +1,6 @@ package tools.dscode.workbench.worker; -import tools.dscode.control.bridge.*; +import tools.dscode.control.protocol.*; import tools.dscode.workbench.bridge.ControlBridgeClient; import java.nio.file.Path; diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java index b4d96fe7..c288305d 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java @@ -1,12 +1,13 @@ package tools.dscode.workbench.worker; -import tools.dscode.control.bridge.ControlBridgeBreakpoint; -import tools.dscode.control.bridge.ControlBridgeCallResult; -import tools.dscode.control.bridge.ControlBridgeScenarioStatus; +import tools.dscode.control.protocol.ControlBridgeBreakpoint; +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ControlBridgeDescriptor; +import tools.dscode.control.protocol.ControlBridgeScenarioStatus; +import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.bridge.ControlBridgeClient; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; -import tools.dscode.testengine.DynamicSuiteBootstrap; import java.io.File; import java.io.IOException; @@ -73,16 +74,16 @@ public synchronized WorkbenchWorkerStatus startInteractive() { .directory(projectRoot.toFile()) .redirectOutput(stdout.toFile()) .redirectError(stderr.toFile()); - builder.environment().put("PKB_CONTROL_BRIDGE_SESSION_DIR", sessionDirectory.toString()); - builder.environment().put("PKB_CONTROL_BRIDGE_SESSION_ID", sessionId); - builder.environment().put("PKB_CONTROL_BRIDGE_TOKEN", token); - builder.environment().put("PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO", "true"); + builder.environment().put(ControlProtocol.SESSION_DIRECTORY_ENV, sessionDirectory.toString()); + builder.environment().put(ControlProtocol.SESSION_ID_ENV, sessionId); + builder.environment().put(ControlProtocol.SESSION_TOKEN_ENV, token); + builder.environment().put(ControlProtocol.PAUSE_FIRST_SCENARIO_ENV, "true"); Process process = null; try { process = builder.start(); WorkerSession session = awaitBridge( - process, sessionId, token, sessionDirectory, stdout, stderr + process, sessionId, token, sessionDirectory, stdout, stderr, manifest, classpath ); active = session; scheduleLeaseRenewal(session); @@ -218,14 +219,14 @@ static List workerCommand( ) { List command = new ArrayList<>(); command.add(javaExecutable().toString()); - command.add("-D" + DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY + command.add("-D" + ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY + "=" + manifest.liveOutputPath()); systemProperties.entrySet().stream() .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) .forEach(entry -> command.add("-D" + entry.getKey() + "=" + entry.getValue())); command.add("-cp"); command.add(String.join(File.pathSeparator, classpath)); - command.add("tools.dscode.testengine.WorkbenchWorkerMain"); + command.add(ControlProtocol.WORKER_MAIN_CLASS); command.add("--tags"); command.add("@pickleball-workbench-anchor"); command.add(anchorFeature.toAbsolutePath().normalize().toUri().toString()); @@ -238,7 +239,9 @@ private WorkerSession awaitBridge( String token, Path sessionDirectory, Path stdout, - Path stderr + Path stderr, + WorkbenchManifest manifest, + List classpath ) { long deadline = System.nanoTime() + START_TIMEOUT.toNanos(); while (System.nanoTime() < deadline) { @@ -252,12 +255,27 @@ private WorkerSession awaitBridge( Path descriptor = firstDescriptor(sessionDirectory); if (descriptor != null) { ControlBridgeClient client; - List scenarios; try { client = ControlBridgeClient.fromDescriptor(descriptor, token); + } catch (IllegalArgumentException incompatibleDescriptor) { + // A complete descriptor with an incompatible protocol or host is + // never made valid by retrying the same consumer process. + throw incompatibleDescriptor; + } catch (RuntimeException ignored) { + // A descriptor is atomically published, but tolerate a short read race. + sleep(50); + continue; + } + + // Origin and process-boundary failures are permanent safety failures. + // Keep this outside the startup retry block so they are reported clearly. + verifyConsumerRuntime(client.descriptor(), manifest, classpath); + + List scenarios; + try { scenarios = client.scenarios(); } catch (RuntimeException ignored) { - // Descriptor may have been published just before the HTTP server is ready. + // Descriptor publication can precede the first accepted HTTP request. sleep(50); continue; } @@ -286,6 +304,108 @@ private WorkerSession awaitBridge( ); } + static void verifyConsumerRuntime( + ControlBridgeDescriptor descriptor, + WorkbenchManifest manifest, + List classpath + ) { + if (descriptor.pid() <= 0) { + throw new IllegalStateException("Consumer worker reported an invalid process id."); + } + if (descriptor.pid() == ProcessHandle.current().pid()) { + throw new IllegalStateException( + "Consumer worker must run in a process distinct from the Workbench controller." + ); + } + if (descriptor.runtimeCodeSource() == null + || descriptor.runtimeCodeSource().isBlank() + || "unknown".equals(descriptor.runtimeCodeSource())) { + throw new IllegalStateException( + "Consumer worker did not report the Pickleball runtime code source." + ); + } + + Path runtimeSource = canonicalPath(Path.of(descriptor.runtimeCodeSource())); + Path consumerProject = canonicalPath(Path.of(manifest.projectRoot())); + List capturedClasspath = classpath.stream() + .map(Path::of) + .map(path -> path.isAbsolute() ? path : consumerProject.resolve(path)) + .map(WorkbenchWorkerManager::canonicalPath) + .toList(); + long runtimeSourceMatches = capturedClasspath.stream() + .filter(runtimeSource::equals) + .count(); + if (runtimeSourceMatches == 0) { + throw new IllegalStateException( + "Consumer worker loaded Pickleball outside the synchronized test runtime classpath: " + + runtimeSource + ); + } + if (runtimeSourceMatches != 1) { + throw new IllegalStateException( + "Consumer worker Pickleball code source must appear exactly once on the " + + "synchronized test runtime classpath: " + runtimeSource + ); + } + + Path controllerSource = codeSource(WorkbenchWorkerManager.class); + boolean controllerOnWorkerClasspath = capturedClasspath.stream().anyMatch(path -> + (controllerSource != null && controllerSource.equals(path)) + || (path.getFileName() != null + && path.getFileName().toString().matches( + "(?i)pickleball-workbench(?:-[^/]*)?\\.jar" + )) + ); + if (controllerOnWorkerClasspath) { + throw new IllegalStateException( + "Consumer worker classpath must not contain the Workbench controller artifact." + ); + } + if (controllerSource != null && controllerSource.equals(runtimeSource)) { + throw new IllegalStateException( + "Consumer worker must not load Pickleball core from the Workbench controller artifact." + ); + } + + if (manifest.pickleballVersion() == null || manifest.pickleballVersion().isBlank()) { + throw new IllegalStateException( + "Synchronized Workbench manifest did not record its Pickleball version." + ); + } + if (descriptor.runtimeVersion() == null || descriptor.runtimeVersion().isBlank()) { + throw new IllegalStateException( + "Consumer worker did not report its Pickleball runtime version." + ); + } + if (!"development".equals(manifest.pickleballVersion()) + && !"development".equals(descriptor.runtimeVersion()) + && !manifest.pickleballVersion().equals(descriptor.runtimeVersion())) { + throw new IllegalStateException( + "Consumer worker Pickleball version " + descriptor.runtimeVersion() + + " does not match synchronized version " + manifest.pickleballVersion() + "." + ); + } + } + + private static Path codeSource(Class type) { + try { + var source = type.getProtectionDomain().getCodeSource(); + if (source == null || source.getLocation() == null) return null; + return canonicalPath(Path.of(source.getLocation().toURI())); + } catch (Exception ignored) { + return null; + } + } + + private static Path canonicalPath(Path path) { + Path normalized = path.toAbsolutePath().normalize(); + try { + return normalized.toRealPath(); + } catch (IOException ignored) { + return normalized; + } + } + private static ControlBridgeScenarioStatus awaitInteractivePause( Process process, diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java new file mode 100644 index 00000000..959fe5b3 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java @@ -0,0 +1,17 @@ +package tools.dscode.workbench; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class WorkbenchRuntimeBoundaryTest { + @Test + void workbenchTestProcessCannotLoadConsumerRuntimeClasses() { + assertDoesNotThrow(WorkbenchRuntimeBoundary::verify); + assertThrows( + ClassNotFoundException.class, + () -> Class.forName("tools.dscode.testengine.WorkbenchWorkerMain") + ); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java index 93b0bb74..06c4edf2 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java @@ -1,17 +1,20 @@ package tools.dscode.workbench.bridge; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import tools.dscode.control.bridge.ControlBridgeBootstrap; -import tools.dscode.control.bridge.ControlBridgeBreakpoint; -import tools.dscode.control.bridge.ControlBridgeDescriptor; -import tools.dscode.control.bridge.ControlBridgeStatus; +import tools.dscode.control.protocol.*; -import java.nio.file.Files; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.List; import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -19,35 +22,33 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class ControlBridgeClientTest { + private static final ObjectMapper JSON = new ObjectMapper(); @TempDir Path tempDir; + private HttpServer server; + private String expectedToken; + private final List breakpoints = new CopyOnWriteArrayList<>(); + @AfterEach - void stopBridge() { - ControlBridgeBootstrap.stop(); + void stopServer() { + if (server != null) server.stop(0); } @Test - void clientUsesThePublishedPickleballBridgeContract() throws Exception { - String token = "phase-2-token"; - ControlBridgeDescriptor descriptor = - ControlBridgeBootstrap.start(tempDir, "phase-2-session", token, false); - - Path descriptorFile; - try (var files = Files.list(tempDir)) { - descriptorFile = files - .filter(path -> path.getFileName().toString().startsWith("runtime-")) - .findFirst() - .orElseThrow(); - } + void clientUsesOnlyTheNeutralWireContract() throws Exception { + String token = "controller-only-token"; + ControlBridgeDescriptor descriptor = startProtocolServer(token); + Path descriptorFile = tempDir.resolve("runtime-" + descriptor.runtimeId() + ".json"); + JSON.writeValue(descriptorFile.toFile(), descriptor); ControlBridgeClient client = ControlBridgeClient.fromDescriptor(descriptorFile, token); ControlBridgeStatus status = client.status(); assertEquals(descriptor.runtimeId(), client.descriptor().runtimeId()); assertEquals(descriptor.runtimeId(), status.runtimeId()); - assertEquals(1, status.protocolVersion()); + assertEquals(ControlProtocol.CURRENT_VERSION, status.protocolVersion()); assertEquals("127.0.0.1", descriptor.host()); assertTrue(client.scenarios().isEmpty()); assertTrue(client.events(null, 0L, 10).events().isEmpty()); @@ -70,14 +71,8 @@ void clientUsesThePublishedPickleballBridgeContract() throws Exception { "UNAVAILABLE", client.mappingPut(missingScenario, "OVERRIDE", "missing", "value", 1).status() ); - assertEquals( - "UNAVAILABLE", - client.mappingResolve(missingScenario, "", 1).status() - ); - assertEquals( - "UNAVAILABLE", - client.mappingSnapshot(missingScenario, "OVERRIDE", 1).status() - ); + assertEquals("UNAVAILABLE", client.mappingResolve(missingScenario, "", 1).status()); + assertEquals("UNAVAILABLE", client.mappingSnapshot(missingScenario, "OVERRIDE", 1).status()); assertEquals("UNAVAILABLE", client.browserPage(missingScenario, 1).status()); assertEquals("UNAVAILABLE", client.browserScreenshot(missingScenario, 1).status()); assertEquals( @@ -96,15 +91,7 @@ void clientUsesThePublishedPickleballBridgeContract() throws Exception { missingScenario, "missing", "^MISSING$", - """ - import tools.dscode.control.override.StepOverrideContext; - import tools.dscode.control.override.StepOverrideHandler; - public final class {{CLASS_NAME}} implements StepOverrideHandler { - public Object execute(StepOverrideContext context) { - return null; - } - } - """, + "public final class {{CLASS_NAME}} {}", 1 ).status() ); @@ -112,76 +99,254 @@ public Object execute(StepOverrideContext context) { assertEquals(0, client.clearStepOverrides(missingScenario)); ControlBridgeBreakpoint breakpoint = client.addBreakpoint( - null, "AFTER_STEP", null, "phase-2-marker", null, true, 30 - ); - assertTrue( - client.breakpoints().stream() - .anyMatch(candidate -> candidate.breakpointId().equals(breakpoint.breakpointId())) + null, "AFTER_STEP", null, "controller-marker", null, true, 30 ); + assertTrue(client.breakpoints().stream() + .anyMatch(candidate -> candidate.breakpointId().equals(breakpoint.breakpointId()))); assertTrue(client.removeBreakpoint(breakpoint.breakpointId())); assertEquals(0, client.clearBreakpoints()); + + assertThrows( + ClassNotFoundException.class, + () -> Class.forName("tools.dscode.testengine.DynamicSuiteBootstrap") + ); } @Test - void wrongBearerTokenIsRejected() { - ControlBridgeDescriptor descriptor = - ControlBridgeBootstrap.start(tempDir, "phase-2-session", "correct-token", false); + void wrongBearerTokenIsRejected() throws Exception { + ControlBridgeDescriptor descriptor = startProtocolServer("correct-token"); ControlBridgeClient client = new ControlBridgeClient(descriptor, "wrong-token"); - IllegalStateException failure = - assertThrows(IllegalStateException.class, client::status); + IllegalStateException failure = assertThrows(IllegalStateException.class, client::status); assertTrue(failure.getMessage().contains("HTTP 401")); } @Test - void descriptorMustUseTheSupportedLoopbackProtocol() { - ControlBridgeDescriptor nonLoopback = descriptor("localhost", 1); - ControlBridgeDescriptor wrongProtocol = descriptor("127.0.0.1", 2); + void descriptorRequiresLoopbackCompatibleVersionAndCapabilities() { + ControlBridgeDescriptor nonLoopback = descriptor( + "localhost", + ControlProtocol.CURRENT_VERSION, + ControlProtocol.MINIMUM_COMPATIBLE_VERSION, + ControlProtocol.WORKER_CAPABILITIES + ); + ControlBridgeDescriptor wrongProtocol = descriptor( + "127.0.0.1", + 1, + 1, + ControlProtocol.WORKER_CAPABILITIES + ); + ControlBridgeDescriptor missingCapability = descriptor( + "127.0.0.1", + ControlProtocol.CURRENT_VERSION, + ControlProtocol.MINIMUM_COMPATIBLE_VERSION, + List.of("status") + ); IllegalArgumentException hostFailure = assertThrows( IllegalArgumentException.class, - () -> new ControlBridgeClient(nonLoopback, "token").status() + () -> new ControlBridgeClient(nonLoopback, "token") ); IllegalArgumentException protocolFailure = assertThrows( IllegalArgumentException.class, - () -> new ControlBridgeClient(wrongProtocol, "token").status() + () -> new ControlBridgeClient(wrongProtocol, "token") + ); + IllegalArgumentException capabilityFailure = assertThrows( + IllegalArgumentException.class, + () -> new ControlBridgeClient(missingCapability, "token") ); assertTrue(hostFailure.getMessage().contains("not loopback-bound")); - assertTrue(protocolFailure.getMessage().contains("Unsupported control bridge protocol")); + assertTrue(protocolFailure.getMessage().contains("Incompatible control bridge protocol")); + assertTrue(capabilityFailure.getMessage().contains("missing required Workbench capabilities")); } @Test - void canonicalEnvironmentNamesAreWorkbenchNeutral() { + void canonicalEnvironmentAndWorkerContractsAreControllerNeutral() { + assertEquals("PKB_CONTROL_BRIDGE_SESSION_DIR", ControlProtocol.SESSION_DIRECTORY_ENV); + assertEquals("PKB_CONTROL_BRIDGE_SESSION_ID", ControlProtocol.SESSION_ID_ENV); + assertEquals("PKB_CONTROL_BRIDGE_TOKEN", ControlProtocol.SESSION_TOKEN_ENV); assertEquals( - "PKB_CONTROL_BRIDGE_SESSION_DIR", - ControlBridgeBootstrap.ENV_SESSION_DIR + "PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO", + ControlProtocol.PAUSE_FIRST_SCENARIO_ENV ); assertEquals( - "PKB_CONTROL_BRIDGE_SESSION_ID", - ControlBridgeBootstrap.ENV_SESSION_ID + "tools.dscode.testengine.WorkbenchWorkerMain", + ControlProtocol.WORKER_MAIN_CLASS ); - assertEquals( - "PKB_CONTROL_BRIDGE_TOKEN", - ControlBridgeBootstrap.ENV_TOKEN + } + + private ControlBridgeDescriptor startProtocolServer(String token) throws IOException { + expectedToken = token; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + return descriptor( + "127.0.0.1", + ControlProtocol.CURRENT_VERSION, + ControlProtocol.MINIMUM_COMPATIBLE_VERSION, + ControlProtocol.WORKER_CAPABILITIES ); - assertEquals( - "PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO", - ControlBridgeBootstrap.ENV_PAUSE_FIRST_SCENARIO + } + + private void handle(HttpExchange exchange) throws IOException { + try (exchange) { + if (!("Bearer " + expectedToken).equals( + exchange.getRequestHeaders().getFirst("Authorization") + )) { + send(exchange, 401, java.util.Map.of("error", "unauthorized")); + return; + } + + String path = exchange.getRequestURI().getPath(); + Object response; + if ("/v1/status".equals(path)) { + response = status(); + } else if ("/v1/scenarios".equals(path)) { + response = List.of(); + } else if ("/v1/events".equals(path)) { + response = new ControlBridgeEventPage(List.of(), 0, 1, 0, false, false); + } else if ("/v1/breakpoints".equals(path)) { + response = List.copyOf(breakpoints); + } else if ("/v1/breakpoints/add".equals(path)) { + ControlBridgeRequests.BreakpointAddRequest request = read( + exchange, + ControlBridgeRequests.BreakpointAddRequest.class + ); + ControlBridgeBreakpoint breakpoint = new ControlBridgeBreakpoint( + "bp-1", request.scenarioId(), request.hook(), request.signatureContains(), + request.stepContains(), request.phraseContains(), + Boolean.TRUE.equals(request.oneShot()), + request.leaseSeconds() == null ? 120 : request.leaseSeconds(), + 0, null, null + ); + breakpoints.add(breakpoint); + response = breakpoint; + } else if ("/v1/breakpoints/remove".equals(path)) { + ControlBridgeRequests.BreakpointIdRequest request = read( + exchange, + ControlBridgeRequests.BreakpointIdRequest.class + ); + response = new ControlBridgeResponses.Removal( + breakpoints.removeIf(value -> value.breakpointId().equals(request.breakpointId())) + ); + } else if ("/v1/breakpoints/clear".equals(path)) { + int removed = breakpoints.size(); + breakpoints.clear(); + response = new ControlBridgeResponses.ClearResult(removed); + } else if ("/v1/step-overrides".equals(path)) { + response = List.of(); + } else if ("/v1/step-overrides/compile".equals(path)) { + read(exchange, ControlBridgeRequests.StepOverrideCompileRequest.class); + response = new ControlBridgeStepOverrideResult( + "UNAVAILABLE", null, unavailableError(), status() + ); + } else if ("/v1/step-overrides/remove".equals(path)) { + read(exchange, ControlBridgeRequests.StepOverrideIdRequest.class); + response = new ControlBridgeResponses.Removal(false); + } else if ("/v1/step-overrides/clear".equals(path)) { + read(exchange, ControlBridgeRequests.StepOverrideScenarioRequest.class); + response = new ControlBridgeResponses.ClearResult(0); + } else { + response = unavailableResponse(path, exchange); + } + send(exchange, 200, response); + } + } + + private Object unavailableResponse(String path, HttpExchange exchange) throws IOException { + if (path.startsWith("/v1/mappings/get") + || path.startsWith("/v1/mappings/put") + || path.startsWith("/v1/mappings/resolve")) { + exchange.getRequestBody().readAllBytes(); + return new ControlBridgeValueResult("UNAVAILABLE", null, unavailableError(), status()); + } + if (path.startsWith("/v1/mappings/snapshot")) { + exchange.getRequestBody().readAllBytes(); + return new ControlBridgeMappingSnapshotResult( + "UNAVAILABLE", null, unavailableError(), status() + ); + } + if (path.startsWith("/v1/browser/page")) { + exchange.getRequestBody().readAllBytes(); + return new ControlBridgeBrowserPageResult( + "UNAVAILABLE", null, unavailableError(), status() + ); + } + if (path.startsWith("/v1/browser/screenshot")) { + exchange.getRequestBody().readAllBytes(); + return new ControlBridgeBrowserScreenshotResult( + "UNAVAILABLE", null, unavailableError(), status() + ); + } + if (path.startsWith("/v1/browser/elements")) { + exchange.getRequestBody().readAllBytes(); + return new ControlBridgeElementInspectionResult( + "UNAVAILABLE", null, unavailableError(), status() + ); + } + if (path.startsWith("/v1/services/call")) { + exchange.getRequestBody().readAllBytes(); + return new ControlBridgeServiceCallResult( + "UNAVAILABLE", null, unavailableError(), status() + ); + } + exchange.getRequestBody().readAllBytes(); + return new ControlBridgeCallResult("UNAVAILABLE", null, null, unavailableError(), status()); + } + + private T read(HttpExchange exchange, Class type) throws IOException { + return JSON.readValue(exchange.getRequestBody(), type); + } + + private static void send(HttpExchange exchange, int status, Object value) throws IOException { + byte[] body = JSON.writeValueAsBytes(value); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + } + + private ControlBridgeStatus status() { + return new ControlBridgeStatus( + ControlProtocol.CURRENT_VERSION, + "runtime", + 42L, + 0, + null, + null, + null, + null, + null, + null, + null, + false, + false, + ControlProtocol.WORKER_CAPABILITIES ); } - private static ControlBridgeDescriptor descriptor(String host, int protocolVersion) { + private static ControlBridgeError unavailableError() { + return new ControlBridgeError("UNAVAILABLE", "No active scenario.", ""); + } + + private ControlBridgeDescriptor descriptor( + String host, + int protocolVersion, + int minimumCompatibleVersion, + List capabilities + ) { return new ControlBridgeDescriptor( protocolVersion, + minimumCompatibleVersion, "session", "runtime", - ProcessHandle.current().pid(), + 42L, host, - 1, - "2026-08-18T00:00:00Z", - List.of("status") + server == null ? 1 : server.getAddress().getPort(), + "2026-08-20T00:00:00Z", + "2.1.9", + tempDir.resolve("consumer-pickleball.jar").toString(), + capabilities ); } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java index ebd1cfba..1fb99ba7 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java @@ -1,21 +1,22 @@ package tools.dscode.workbench.ui; import org.junit.jupiter.api.Test; -import tools.dscode.control.api.ServiceCallEvidence; -import tools.dscode.control.bridge.ControlBridgeBreakpoint; -import tools.dscode.control.bridge.ControlBridgeBrowserPage; -import tools.dscode.control.bridge.ControlBridgeBrowserPageResult; -import tools.dscode.control.bridge.ControlBridgeBrowserScreenshot; -import tools.dscode.control.bridge.ControlBridgeBrowserScreenshotResult; -import tools.dscode.control.bridge.ControlBridgeCallResult; -import tools.dscode.control.bridge.ControlBridgeEvent; -import tools.dscode.control.bridge.ControlBridgeEventPage; -import tools.dscode.control.bridge.ControlBridgeServiceCallResult; -import tools.dscode.control.bridge.ControlBridgeStatus; -import tools.dscode.control.bridge.ControlBridgeStepOverride; -import tools.dscode.control.bridge.ControlBridgeStepOverrideResult; -import tools.dscode.control.bridge.ControlBridgeValue; -import tools.dscode.control.bridge.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlBridgeBreakpoint; +import tools.dscode.control.protocol.ControlBridgeBrowserPage; +import tools.dscode.control.protocol.ControlBridgeBrowserPageResult; +import tools.dscode.control.protocol.ControlBridgeBrowserScreenshot; +import tools.dscode.control.protocol.ControlBridgeBrowserScreenshotResult; +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ControlBridgeEvent; +import tools.dscode.control.protocol.ControlBridgeEventPage; +import tools.dscode.control.protocol.ControlBridgeServiceCallEvidence; +import tools.dscode.control.protocol.ControlBridgeServiceCallResult; +import tools.dscode.control.protocol.ControlBridgeStatus; +import tools.dscode.control.protocol.ControlBridgeStepOverride; +import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; +import tools.dscode.control.protocol.ControlBridgeValue; +import tools.dscode.control.protocol.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.WorkbenchServices; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -249,7 +250,7 @@ private static WorkbenchWorkerStatus running(long pid) { private static ControlBridgeStatus runtime() { return new ControlBridgeStatus( - 1, + ControlProtocol.CURRENT_VERSION, "runtime-101", 101L, 1, @@ -414,7 +415,9 @@ yield new ControlBridgeBrowserScreenshotResult( calls.add("serviceCall:" + args[0]); yield new ControlBridgeServiceCallResult( "SUCCESS", - new ServiceCallEvidence((String) args[0], null, null, null, 200), + new ControlBridgeServiceCallEvidence( + (String) args[0], null, null, null, 200 + ), null, runtime() ); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java index ff632fa2..a4456e10 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java @@ -2,8 +2,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import tools.dscode.control.protocol.ControlBridgeDescriptor; +import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.sync.WorkbenchManifest; -import tools.dscode.testengine.DynamicSuiteBootstrap; import java.io.File; import java.nio.file.Path; @@ -12,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class WorkbenchWorkerManagerTest { @@ -35,7 +37,7 @@ void directWorkerCommandUsesOnlyLiveOutputAndCapturedDependencies() { assertTrue(command.get(0).endsWith(WorkbenchProjectOs.javaName())); assertTrue(command.contains( - "-D" + DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY + "=" + live + "-D" + ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY + "=" + live )); int classpathIndex = command.indexOf("-cp") + 1; assertEquals( @@ -46,7 +48,7 @@ void directWorkerCommandUsesOnlyLiveOutputAndCapturedDependencies() { .map(Path::of) .map(path -> path.toAbsolutePath().normalize()) .anyMatch(path -> path.startsWith(project.resolve(".pickleball/workbench/base")))); - assertTrue(command.contains("tools.dscode.testengine.WorkbenchWorkerMain")); + assertTrue(command.contains(ControlProtocol.WORKER_MAIN_CLASS)); int tagIndex = command.indexOf("--tags"); assertEquals("@pickleball-workbench-anchor", command.get(tagIndex + 1)); assertEquals(anchor.toUri().toString(), command.getLast()); @@ -67,7 +69,7 @@ void workerSystemPropertiesAreExplicitAndDeterministic() { ); int outputRoot = command.indexOf( - "-D" + DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY + "=" + live + "-D" + ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY + "=" + live ); assertEquals("-Dpkb_browser=CHROME_HEADLESS", command.get(outputRoot + 1)); assertEquals("-Dpkb_tags=@smoke", command.get(outputRoot + 2)); @@ -90,6 +92,110 @@ void anchorUsesGuaranteedNoOpCoreStep() { assertFalse(feature.contains("@all")); } + @Test + void consumerRuntimeMustBeASeparateProcessLoadedFromCapturedClasspath() { + Path project = tempDir.resolve("consumer").toAbsolutePath().normalize(); + Path live = project.resolve(".pickleball/workbench/live/classes"); + Path dependency = tempDir.resolve("pickleball.jar").toAbsolutePath().normalize(); + WorkbenchManifest manifest = manifest(project, live, dependency); + ControlBridgeDescriptor descriptor = descriptor( + ProcessHandle.current().pid() + 1, + dependency, + "2.1.8" + ); + + WorkbenchWorkerManager.verifyConsumerRuntime( + descriptor, + manifest, + List.of(live.toString(), dependency.toString()) + ); + } + + @Test + void consumerRuntimeRejectsControllerPidUncapturedOriginAndVersionDrift() { + Path project = tempDir.resolve("consumer").toAbsolutePath().normalize(); + Path live = project.resolve(".pickleball/workbench/live/classes"); + Path dependency = tempDir.resolve("pickleball.jar").toAbsolutePath().normalize(); + Path foreign = tempDir.resolve("controller/pickleball.jar").toAbsolutePath().normalize(); + Path controllerJar = tempDir.resolve("pickleball-workbench-2.1.8.jar") + .toAbsolutePath() + .normalize(); + WorkbenchManifest manifest = manifest(project, live, dependency); + List classpath = List.of(live.toString(), dependency.toString()); + + IllegalStateException pidFailure = assertThrows( + IllegalStateException.class, + () -> WorkbenchWorkerManager.verifyConsumerRuntime( + descriptor(ProcessHandle.current().pid(), dependency, "2.1.8"), + manifest, + classpath + ) + ); + IllegalStateException originFailure = assertThrows( + IllegalStateException.class, + () -> WorkbenchWorkerManager.verifyConsumerRuntime( + descriptor(ProcessHandle.current().pid() + 1, foreign, "2.1.8"), + manifest, + classpath + ) + ); + IllegalStateException versionFailure = assertThrows( + IllegalStateException.class, + () -> WorkbenchWorkerManager.verifyConsumerRuntime( + descriptor(ProcessHandle.current().pid() + 1, dependency, "9.9.9"), + manifest, + classpath + ) + ); + IllegalStateException controllerLeakFailure = assertThrows( + IllegalStateException.class, + () -> WorkbenchWorkerManager.verifyConsumerRuntime( + descriptor(ProcessHandle.current().pid() + 1, dependency, "2.1.8"), + manifest, + List.of(live.toString(), dependency.toString(), controllerJar.toString()) + ) + ); + IllegalStateException duplicateOriginFailure = assertThrows( + IllegalStateException.class, + () -> WorkbenchWorkerManager.verifyConsumerRuntime( + descriptor(ProcessHandle.current().pid() + 1, dependency, "2.1.8"), + manifest, + List.of(live.toString(), dependency.toString(), dependency.toString()) + ) + ); + IllegalStateException missingVersionFailure = assertThrows( + IllegalStateException.class, + () -> WorkbenchWorkerManager.verifyConsumerRuntime( + descriptor(ProcessHandle.current().pid() + 1, dependency, null), + manifest, + classpath + ) + ); + + assertTrue(pidFailure.getMessage().contains("distinct")); + assertTrue(originFailure.getMessage().contains("outside the synchronized")); + assertTrue(versionFailure.getMessage().contains("does not match")); + assertTrue(controllerLeakFailure.getMessage().contains("must not contain")); + assertTrue(duplicateOriginFailure.getMessage().contains("exactly once")); + assertTrue(missingVersionFailure.getMessage().contains("did not report")); + } + + private ControlBridgeDescriptor descriptor(long pid, Path runtimeSource, String version) { + return new ControlBridgeDescriptor( + ControlProtocol.CURRENT_VERSION, + ControlProtocol.MINIMUM_COMPATIBLE_VERSION, + "session", + "runtime", + pid, + "127.0.0.1", + 1, + "2026-08-20T00:00:00Z", + version, + runtimeSource.toString(), + ControlProtocol.WORKER_CAPABILITIES + ); + } + private WorkbenchManifest manifest(Path project, Path live, Path dependency) { return new WorkbenchManifest( 1, diff --git a/scripts/agent_validate.ps1 b/scripts/agent_validate.ps1 index f2d20368..ad4f0574 100644 --- a/scripts/agent_validate.ps1 +++ b/scripts/agent_validate.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] param( [switch]$Quick, + [switch]$Workbench, [string]$MavenCommand ) @@ -145,10 +146,36 @@ try { Invoke-Python scripts/refresh_agent_index.py --check Invoke-Python scripts/sync_consumer_guidance.py --check - & .\gradlew.bat test - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($Quick -and $Workbench) { + throw "Choose either -Quick or -Workbench, not both." + } + + if ($Workbench) { + & .\gradlew.bat verifyStrictControllerIsolation :pickleball-workbench:test publishToMavenLocal + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $MavenExe = Get-MavenExecutable -RequestedCommand $MavenCommand + Write-Host "Using Maven command: $MavenExe" + foreach ($FocusedTag in @("@control-bridge", "@step-override-bridge")) { + $MavenArgs = @( + "-f", + "maven-consumer-project/pom.xml", + "-U", + "test", + "-Dpkb_runvars.pkb_browser=CHROME_HEADLESS", + "-Dpkb_runvars.pkb_parallel=80", + "-Dpkb_runvars.pkb_tags=$FocusedTag" + ) + & $MavenExe @MavenArgs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + } + else { + & .\gradlew.bat test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } - if (-not $Quick) { + if (-not $Quick -and -not $Workbench) { & .\gradlew.bat publishToMavenLocal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -167,7 +194,7 @@ try { if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - $Mode = if ($Quick) { "quick" } else { "full" } + $Mode = if ($Quick) { "quick" } elseif ($Workbench) { "workbench" } else { "full" } Write-Host "Pickleball validation completed ($Mode mode)." } finally { diff --git a/scripts/agent_validate.sh b/scripts/agent_validate.sh index 39d611e4..e63383b0 100644 --- a/scripts/agent_validate.sh +++ b/scripts/agent_validate.sh @@ -6,15 +6,30 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." mode="full" if [[ "${1:-}" == "--quick" ]]; then mode="quick" +elif [[ "${1:-}" == "--workbench" ]]; then + mode="workbench" elif [[ $# -gt 0 ]]; then - echo "Usage: scripts/agent_validate.sh [--quick]" >&2 + echo "Usage: scripts/agent_validate.sh [--quick|--workbench]" >&2 exit 2 fi python3 scripts/verify_agent_contract.py python3 scripts/refresh_agent_index.py --check python3 scripts/sync_consumer_guidance.py --check -./gradlew test + +if [[ "$mode" == "workbench" ]]; then + ./gradlew verifyStrictControllerIsolation :pickleball-workbench:test publishToMavenLocal + bash ./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test \ + -Dpkb_runvars.pkb_browser=CHROME_HEADLESS \ + -Dpkb_runvars.pkb_parallel=80 \ + -Dpkb_runvars.pkb_tags=@control-bridge + bash ./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test \ + -Dpkb_runvars.pkb_browser=CHROME_HEADLESS \ + -Dpkb_runvars.pkb_parallel=80 \ + -Dpkb_runvars.pkb_tags=@step-override-bridge +else + ./gradlew test +fi if [[ "$mode" == "full" ]]; then ./gradlew publishToMavenLocal diff --git a/scripts/refresh_agent_index.py b/scripts/refresh_agent_index.py index ce4a3736..d498019f 100644 --- a/scripts/refresh_agent_index.py +++ b/scripts/refresh_agent_index.py @@ -53,6 +53,11 @@ "pickleball-control-api/src/main/**/*", "pickleball-control-api/src/test/**/*", )), + ("Neutral control protocol module", ( + "pickleball-control-protocol/build.gradle", + "pickleball-control-protocol/src/main/**/*", + "pickleball-control-protocol/src/test/**/*", + )), ("Pickleball Workbench module", ( "gradle/pickleball-published-variant.gradle", "pickleball-workbench/AGENTS.md", diff --git a/scripts/verify_agent_contract.py b/scripts/verify_agent_contract.py index ed3ccb10..7ce81450 100644 --- a/scripts/verify_agent_contract.py +++ b/scripts/verify_agent_contract.py @@ -39,6 +39,7 @@ "docs/agent/change-checklist.md", "docs/agent/prompt-examples.md", "docs/agent/repository-index.md", + "pickleball-workbench/AGENTS.md", "gradle/consumer-guidance.gradle", "scripts/refresh_agent_index.py", "scripts/sync_consumer_guidance.py", @@ -103,15 +104,24 @@ "src/main/java/", "src/main/aspectj/", "src/main/resources/", + "pickleball-control-protocol/src/main/", + "pickleball-control-api/src/main/", + "pickleball-workbench/src/main/", ) BEHAVIOR_FILES = { "build.gradle", "settings.gradle", "gradle/consumer-guidance.gradle", + "pickleball-control-protocol/build.gradle", + "pickleball-control-api/build.gradle", + "pickleball-workbench/build.gradle", "gradle.properties", } TEST_PREFIXES = ( "src/test/", + "pickleball-control-protocol/src/test/", + "pickleball-control-api/src/test/", + "pickleball-workbench/src/test/", "maven-consumer-project/src/test/java/", "maven-consumer-project/src/test/resources/features/", "maven-consumer-project/src/test/resources/calls/", @@ -123,10 +133,100 @@ ) DOC_FILES = { "README.md", + "pickleball-workbench/AGENTS.md", "maven-consumer-project/README.md", "maven-consumer-project/AGENTS.md", } +WORKBENCH_CONTRACT_FILES = ( + "AGENTS.md", + "pickleball-workbench/AGENTS.md", + "docs/agent/feature-map.md", +) + + +def validate_workbench_controller_contract(errors: list[str]) -> None: + required_fragments = ( + "pickleball-control-protocol", + "Pickleball may contain Workbench", + "Workbench must not contain Pickleball", + "@control-bridge", + "pkb_parallel=80", + ) + forbidden_fragments = ( + "pickleball-workbench -> pickleball", + "Workbench POM contract is exactly `tools.dscode:pickleball`", + "uses the public `tools.dscode.control.bridge.*`", + ) + + for relative in WORKBENCH_CONTRACT_FILES: + path = ROOT / relative + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + for required in required_fragments: + if required not in text: + errors.append( + f"Workbench controller guidance must retain {required!r}: {relative}" + ) + for forbidden in forbidden_fragments: + if forbidden in text: + errors.append( + f"Workbench controller guidance retains the obsolete dependency rule " + f"{forbidden!r}: {relative}" + ) + + +def validate_workbench_source_boundary(errors: list[str]) -> None: + build = ROOT / "pickleball-workbench" / "build.gradle" + if build.is_file(): + text = build.read_text(encoding="utf-8") + if "implementation project(':pickleball-control-protocol')" not in text: + errors.append( + "Workbench must depend on exactly the neutral protocol project: " + "pickleball-workbench/build.gradle" + ) + for forbidden in ( + "pickleballPublishedElements", + "project(path: ':'", + "implementation project(':')", + "tools.dscode:pickleball", + ): + if forbidden in text: + errors.append( + f"Workbench build restores a forbidden core dependency ({forbidden}): " + "pickleball-workbench/build.gradle" + ) + + workbench_sources = ROOT / "pickleball-workbench" / "src" / "main" / "java" + if workbench_sources.is_dir(): + for source in workbench_sources.rglob("*.java"): + for line in source.read_text(encoding="utf-8").splitlines(): + if not line.startswith(( + "import tools.dscode.", + "import static tools.dscode.", + )): + continue + imported = line.removeprefix("import ").removeprefix("static ") + if imported.startswith("tools.dscode.workbench.") or imported.startswith( + "tools.dscode.control.protocol." + ): + continue + errors.append( + "Workbench source imports a Pickleball execution package: " + f"{source.relative_to(ROOT)} -> {line.strip()}" + ) + + protocol_sources = ROOT / "pickleball-control-protocol" / "src" / "main" / "java" + if protocol_sources.is_dir(): + for source in protocol_sources.rglob("*.java"): + for line in source.read_text(encoding="utf-8").splitlines(): + if line.startswith("import ") and not line.startswith("import java."): + errors.append( + "Neutral protocol source has a non-JDK import: " + f"{source.relative_to(ROOT)} -> {line.strip()}" + ) + def env_true(name: str) -> bool: return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} @@ -397,6 +497,8 @@ def main() -> int: validate_consumer_tracked_artifacts(errors) validate_consumer_ignore(errors) validate_packaged_guidance(errors) + validate_workbench_controller_contract(errors) + validate_workbench_source_boundary(errors) if args.base_ref: changed = git_changed_files(args.base_ref) diff --git a/settings.gradle b/settings.gradle index 82831d8c..62bdafb2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,12 +1,12 @@ rootProject.name = 'pickleball' include 'pickleball-control-api' +include 'pickleball-control-protocol' include 'pickleball-workbench' gradle.beforeProject { project -> if (project.path == ':') { project.apply from: new File(settingsDir, 'gradle/pickleball-build-provenance.gradle') project.apply from: new File(settingsDir, 'gradle/consumer-guidance.gradle') - project.apply from: new File(settingsDir, 'gradle/pickleball-published-variant.gradle') } } diff --git a/src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java b/src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java new file mode 100644 index 00000000..82afac0e --- /dev/null +++ b/src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java @@ -0,0 +1,158 @@ +package tools.dscode.launcher; + +import tools.dscode.control.protocol.ControlProtocol; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +/** + * Extracts the version-matched, controller-only Workbench payload from the + * consumer's Pickleball dependency and always launches it in a separate JVM. + */ +public final class PickleballWorkbenchLauncher { + private static final int MAX_PAYLOAD_BYTES = 100 * 1024 * 1024; + private static final Set PROJECT_COMMANDS = Set.of( + "sync", "status", "worker-check", "live-check", "ui", "mcp" + ); + + private PickleballWorkbenchLauncher() { + } + + public static void main(String[] args) { + String[] forwarded = normalizedArguments(args); + Path project = projectRoot(forwarded); + try { + Path controllerJar = extractPayload(project, readEmbeddedPayload()); + Process process = new ProcessBuilder(command(controllerJar, forwarded)) + .directory(project.toFile()) + .inheritIO() + .start(); + int exitCode = process.waitFor(); + if (exitCode != 0) System.exit(exitCode); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + System.err.println("Pickleball Workbench launcher was interrupted."); + System.exit(1); + } catch (RuntimeException | IOException failure) { + System.err.println("Could not launch Pickleball Workbench: " + failure.getMessage()); + System.exit(1); + } + } + + static byte[] readEmbeddedPayload() { + ClassLoader loader = PickleballWorkbenchLauncher.class.getClassLoader(); + try (InputStream input = loader.getResourceAsStream(ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE)) { + if (input == null) { + throw new IllegalStateException( + "Pickleball artifact is missing " + ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE + ); + } + byte[] payload = input.readNBytes(MAX_PAYLOAD_BYTES + 1); + if (payload.length > MAX_PAYLOAD_BYTES) { + throw new IllegalStateException("Embedded Workbench payload exceeds the safety limit."); + } + return payload; + } catch (IOException failure) { + throw new IllegalStateException("Could not read the embedded Workbench payload.", failure); + } + } + + static Path extractPayload(Path projectRoot, byte[] payload) throws IOException { + if (payload == null || payload.length == 0) { + throw new IllegalArgumentException("Workbench payload must not be empty."); + } + Path project = projectRoot.toAbsolutePath().normalize(); + if (!Files.isDirectory(project)) { + throw new IllegalArgumentException( + "Consumer project directory does not exist: " + project + ); + } + String checksum = sha256(payload); + Path directory = project.resolve(".pickleball") + .resolve("workbench") + .resolve("controller") + .resolve(checksum); + Path target = directory.resolve("pickleball-workbench.jar"); + Files.createDirectories(directory); + + if (Files.isRegularFile(target) && checksum.equals(sha256(Files.readAllBytes(target)))) { + return target; + } + + Path temporary = Files.createTempFile(directory, "pickleball-workbench-", ".tmp"); + try { + Files.write(temporary, payload); + try { + Files.move( + temporary, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + + if (!checksum.equals(sha256(Files.readAllBytes(target)))) { + throw new IllegalStateException("Extracted Workbench payload failed checksum verification."); + } + return target; + } + + static List command(Path controllerJar, String[] args) { + List command = new ArrayList<>(); + command.add(javaExecutable().toString()); + command.add("-jar"); + command.add(controllerJar.toAbsolutePath().normalize().toString()); + command.addAll(List.of(args)); + return List.copyOf(command); + } + + static String[] normalizedArguments(String[] args) { + Path currentProject = Path.of("").toAbsolutePath().normalize(); + if (args == null || args.length == 0) { + return new String[]{"ui", currentProject.toString()}; + } + if (args.length == 1 && PROJECT_COMMANDS.contains(args[0])) { + return new String[]{args[0], currentProject.toString()}; + } + return args.clone(); + } + + private static Path projectRoot(String[] args) { + if (args.length == 2 + && PROJECT_COMMANDS.contains(args[0]) + && !args[1].isBlank()) { + return Path.of(args[1]).toAbsolutePath().normalize(); + } + return Path.of("").toAbsolutePath().normalize(); + } + + private static Path javaExecutable() { + String executable = System.getProperty("os.name", "") + .toLowerCase() + .contains("win") ? "java.exe" : "java"; + return Path.of(System.getProperty("java.home"), "bin", executable); + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable.", impossible); + } + } +} diff --git a/src/main/java/tools/dscode/testengine/DynamicSuiteBootstrap.java b/src/main/java/tools/dscode/testengine/DynamicSuiteBootstrap.java index faff70cd..7d893904 100644 --- a/src/main/java/tools/dscode/testengine/DynamicSuiteBootstrap.java +++ b/src/main/java/tools/dscode/testengine/DynamicSuiteBootstrap.java @@ -1,5 +1,7 @@ package tools.dscode.testengine; +import tools.dscode.control.protocol.ControlProtocol; + import org.junit.platform.commons.support.ReflectionSupport; import org.junit.platform.engine.EngineDiscoveryRequest; import org.junit.platform.engine.discovery.ClassSelector; @@ -26,7 +28,7 @@ public final class DynamicSuiteBootstrap { /** Internal direct-worker contract; not a Pickleball RunVar. */ public static final String WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY = - "pickleball.workbench.testOutputRoot"; + ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY; private DynamicSuiteBootstrap() { } diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/README.md index d1f8be4c..106dfc6e 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/README.md @@ -37,7 +37,7 @@ When these docs are materialized from the Maven dependency with `DiagnosticCli e - [AI and automation run configuration](ai-run-configuration.md) — controlled `pkb_runvars`, inherited execution context, retained `pkb_run_profile`, `pkb_configpath`, protected values, and deterministic diagnostic reruns. - [Dynamic control API](dynamic-control-api.md) — optional retry-friendly dynamic Gherkin execution, isolated/scoped ParsingMap control, snapshots, value interception, synchronous semantic hooks, and the consumer-side Control Bridge used by Workbench. - [Step Overrides](step-overrides.md) — live REGEX/REPLACE step implementation authoring in a persistent worker, including generated Java handlers and Workbench management. -- [Pickleball Workbench](pickleball-workbench.md) — separate executable companion with synchronization, persistent live worker control, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and thin Swing UI. +- [Pickleball Workbench](pickleball-workbench.md) — dependency-matched external controller embedded opaquely in Pickleball, with a neutral versioned protocol, strict core-free artifact/process boundary, consumer-classpath worker, MCP stdio, and Swing UI. - [Diagnostic lineage and metadata](diagnostic-lineage-metadata.md) — distinguish lineage annotations, execution/evidence RunVars, controls, and derived evidence. - [Diagnostic reporting](diagnostic-reporting.md) — sparse-first AI evidence, source provenance, step/capability metadata, trace evidence, screenshots/fingerprints, comparison, and retention. - [AI diagnostic reporting plan](ai-diagnostic-reporting-plan.md) — current sparse-first investigation and controlled-rerun architecture. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md index 664905ed..b3324a47 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md @@ -10,6 +10,7 @@ This directory supports repository-native AI coding agents. It is not a runtime - `/docs/agent/feature-map.md` — living map from capabilities to implementation, tests, consumer examples, and documentation - `/docs/agent/change-checklist.md` — explicit change-completion checklist - `/docs/agent/repository-index.md` — generated inventory of relevant files +- `/pickleball-workbench/AGENTS.md` — strict controller/core dependency, artifact, process, worker-classpath, protocol, and focused-test invariants - `/docs/ai-run-configuration.md` — controlled execution through `pkb_runvars`, canonical `pkb_run_profile`, inherited execution context, `pkb_configpath`, replay, and protected values - `/docs/diagnostic-lineage-metadata.md` — investigation lineage and derived diagnostic metadata - `/REVIEW.md` — review-time checks for compatibility, tests, consumer examples, and documentation omissions @@ -58,6 +59,8 @@ A functionality-change agent should: 7. Remove disposable working files. 8. Report results. +Workbench work has an additional hard boundary: `pickleball-workbench` may share only the JDK-only `pickleball-control-protocol`; all execution remains in the consumer worker. The outer Pickleball JAR may carry the completed Workbench as opaque bytes, but Workbench must never contain or load Pickleball. Future agents must not restore the removed root/published-equivalent dependency. Use `verifyStrictControllerIsolation` and focused `@control-bridge` / `@step-override-bridge` scenarios with `pkb_parallel=80`, never `@all`, for this boundary. + For AI-launched tests with known settings, default to `pkb_runvars`. Use ordinary JVM RunVars or named profiles instead only when intentionally exercising those resolution paths. For controlled reruns, follow `/docs/ai-run-configuration.md` and `/docs/diagnostic-lineage-metadata.md`: replay retained `runProfile` through `pkb_runvars`, change only intentional RunVars, keep lineage separate, and verify `runProfileFingerprint`. `pkb_changed_variables` names RunVars only, not source changes or profile controls. This is task-time automation, not a passive background documentation watcher. @@ -93,6 +96,16 @@ Windows: .\scripts\agent_validate.ps1 ``` +Workbench/controller isolation uses the dedicated focused mode, which runs strict artifact/dependency checks and then runs `@control-bridge` and `@step-override-bridge` sequentially, each with `pkb_parallel=80`: + +```shell +scripts/agent_validate.sh --workbench +``` + +```powershell +.\scripts\agent_validate.ps1 -Workbench +``` + ## Enforcement levels By default, `verify_agent_contract.py` treats missing agent files and invalid temporary-workspace configuration as errors; change-coverage findings remain warnings. Strict mode may be used in CI when appropriate. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/change-checklist.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/change-checklist.md index faed2328..c18962f3 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/change-checklist.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/change-checklist.md @@ -27,6 +27,8 @@ Use this checklist for changes to Pickleball behavior. Coding agents should comp - [ ] Cover meaningful edge and compatibility cases. - [ ] When an agent launches Pickleball tests with known execution settings, use `pkb_runvars` as the authoritative input unless the test intentionally exercises normal JVM/profile precedence. - [ ] Never supply `pkb_run_profile` as test input; it is derived output. +- [ ] For Workbench/protocol/worker changes, preserve the JDK-only shared protocol, core-free controller artifact/process, separate consumer worker, consumer-authoritative classpath, and opaque nested payload. +- [ ] Never restore a root/`tools.dscode:pickleball`/behavioral-control dependency to Workbench to fix compilation. ## Maintain knowledge @@ -42,7 +44,10 @@ Use this checklist for changes to Pickleball behavior. Coding agents should comp - [ ] Run `python scripts/sync_consumer_guidance.py --check`. - [ ] Run `./gradlew test`. - [ ] For consumer-visible changes, run `./gradlew publishToMavenLocal`. -- [ ] For consumer-visible changes, run `./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_tags=@all`. +- [ ] For broad consumer-visible changes outside Workbench/controller isolation, run `./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_tags=@all`. +- [ ] For Workbench/controller isolation changes, never run `@all`; run only affected `@control-bridge` and/or `@step-override-bridge` scenarios with `-Dpkb_runvars.pkb_parallel=80` where practical. +- [ ] For Workbench boundary changes, run `./gradlew verifyStrictControllerIsolation :pickleball-workbench:test`. +- [ ] Prefer the equivalent focused turnkey command `scripts/agent_validate.sh --workbench` (PowerShell: `.\scripts\agent_validate.ps1 -Workbench`) when the environment supports the complete flow. - [ ] Report anything not run and the reason. ## Report diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index 87122098..c5376dae 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -4,9 +4,11 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Capability | Implementation/search anchors | Consumer/internal coverage | Canonical documentation | |---|---|---|---| -| Build, publication, Java compatibility | `build.gradle`; `settings.gradle`; `src/main/aspectj`; `gradle/pickleball-published-variant.gradle`; search `publishing`, `shadowJar`, `pickleballPublishedElements`, `aspectj`, `JavaLanguageVersion` | root tests; `:pickleball-workbench:test`; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `gradle/pickleball-published-variant.gradle`; `WorkbenchApplication`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; Pickleball `WorkbenchWorkerMain`; `DynamicSuiteBootstrap.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY` | `:pickleball-workbench:test`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; `DynamicSuiteBootstrapWorkbenchRootTest`; direct `sync` / `worker-check` / `live-check`; packaged UI smoke | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer-side Control Bridge and live investigation | `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core bootstrap `ControlRuntime`; Workbench `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | +| Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -29,18 +31,22 @@ This file maps consumer-visible capabilities to implementation anchors, executab ## Workbench architecture contract -`pickleball-workbench` is a separate executable companion module and depends one-way on the normal Pickleball runtime. The repository build supplies the Workbench through `pickleballPublishedElements`, a dedicated published-equivalent configuration backed by the root shaded/woven `shadowJar` plus the root publication's non-bundled external runtime dependencies. Do not replace that dependency with a naïve root `project(':')` variant and do not expose `pickleball-control-api` as a Workbench publication dependency. +`pickleball-workbench` is an external controller, not a test runtime. Core/worker and Workbench both use the JDK-only `pickleball-control-protocol`; Workbench has no root Pickleball, Maven Pickleball, published-equivalent, or behavioral `pickleball-control-api` dependency. Never resolve a Workbench compilation problem by restoring one of those dependencies or shading core. Pickleball may contain Workbench; Workbench must not contain Pickleball. -The Workbench POM contract is exactly `tools.dscode:pickleball`; Workbench-only implementation libraries are shaded into the executable companion. Build verification checks both directions: the Workbench resolves the shaded Pickleball artifact without separate unwoven Cucumber modules, and the normal Pickleball JAR contains no Workbench classes/resources. +The controller-side `ControlBridgeClient` uses only `tools.dscode.control.protocol.*`. The consumer-hosted `ControlBridgeRuntime`, `ControlBridgeCoordinator`, bootstrap, runtime adapters, Cucumber/Selenium/service behavior, mappings, and Step Override compilation remain in Pickleball. `ControlProtocol` owns protocol version/minimum-version negotiation, capabilities, wire constants, and the worker main-class string; it owns no behavior. -The controller-side `ControlBridgeClient` uses the public `tools.dscode.control.bridge.*` records bundled in `tools.dscode:pickleball`; it does not duplicate parsing, mapping, browser, service-call, breakpoint, Step Override, or detached-execution semantics. The consumer-hosted `ControlBridgeRuntime` and `ControlBridgeCoordinator` remain in Pickleball. +The standalone Workbench shadow JAR is controller-only and self-contained, with an empty published dependency list. Root `shadowJar` consumes the finished file as opaque bytes at `META-INF/pickleball/workbench/pickleball-workbench.jar`. `PickleballWorkbenchLauncher` extracts by SHA-256 under the consumer's `.pickleball/workbench/controller/` state and always starts `java -jar` in a distinct JVM. Workbench/MCP classes are not flattened into the outer Pickleball namespace, and Pickleball/core classes are not present in the nested Workbench. + +`verifyProtocolIsolation`, `verifyWorkbenchRuntimeBoundary`, `verifyWorkbenchArtifact`, `verifyWorkbenchPublishedDependencyContract`, `verifyEmbeddedWorkbench`, and `verifyStrictControllerIsolation` enforce dependency provenance, top-level/nested JAR entries, service providers, exact payload count/bytes, and entry points. `WorkbenchRuntimeBoundary` rejects core visibility in the controller process. `WorkbenchWorkerManager` rejects same-PID workers, non-consumer runtime origins, synchronized-version drift, and any worker classpath containing the Workbench controller. The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `PKB_CONTROL_BRIDGE_SESSION_ID`, `PKB_CONTROL_BRIDGE_TOKEN`, and `PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO`. Pickleball may also accept the prior `PKB_STUDIO_BRIDGE_*` names as deprecated compatibility input aliases. New Workbench code must use only the neutral names. `WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. -`WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through Pickleball's thin `WorkbenchWorkerMain`, without invoking Maven or Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. +`WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through the protocol-owned worker class-name contract, without linking the worker class or invoking Maven/Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the session buffer, stable line identities, selection, playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. + +For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 118cfbec..ebb90e76 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -362,6 +362,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/coredefinitions/TableSteps.java` - `src/main/java/tools/dscode/coredefinitions/UtilitySteps.java` - `src/main/java/tools/dscode/cucumberextended/utilities/StringUtilities.java` +- `src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java` - `src/main/java/tools/dscode/misc/DummySteps.java` - `src/main/java/tools/dscode/parallelutilities/Stagger.java` - `src/main/java/tools/dscode/pickleruntime/CucumberOptionResolver.java` @@ -385,6 +386,7 @@ This inventory helps coding agents discover relevant files. It does not replace ## Framework tests - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` +- `src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` ## Control API module @@ -435,6 +437,38 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValue.java` - `pickleball-control-api/src/main/java/tools/dscode/control/bridge/ControlBridgeValueResult.java` +## Neutral control protocol module + +- `pickleball-control-protocol/build.gradle` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBoundedJsonEvidence.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBreakpoint.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPage.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserPageResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshot.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeBrowserScreenshotResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeCallResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeDescriptor.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementEvidence.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspection.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeElementInspectionResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeError.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEvent.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeEventPage.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeJson.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshot.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeMappingSnapshotResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeRequests.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeResponses.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeScenarioStatus.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallEvidence.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeServiceCallResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStatus.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverride.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeStepOverrideResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java` + ## Pickleball Workbench module - `gradle/pickleball-published-variant.gradle` @@ -452,6 +486,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java` @@ -463,6 +498,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchLiveSessionTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java` @@ -563,7 +599,6 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/files/customers.yaml` - `maven-consumer-project/src/test/resources/pickleball.properties` - `maven-consumer-project/src/test/resources/pickleball_local.properties` -- `maven-consumer-project/src/test/resources/pickleball_local2.properties` - `maven-consumer-project/src/test/resources/profiles.yaml` - `maven-consumer-project/src/test/resources/profiles_local.yaml` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index b9cf6e95..c4954679 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -75,6 +75,23 @@ or use the included wrappers: `PickleballTests` starts the test server on `127.0.0.1:8765` before Cucumber and stops it afterward. +## Launch the dependency-matched Workbench + +The test-scoped Pickleball dependency already contains its controller-only Workbench payload. Start the launcher from the resolved test classpath without finding a Maven cache entry or declaring a second version: + +```bash +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +```powershell +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +``` + +The launcher verifies and extracts the opaque payload beneath `.pickleball/workbench/controller//`, then creates a separate Workbench JVM. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. UI and MCP both use the same controller/live session. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. + Runner defaults include: - glue `com.example.pickleball`; @@ -128,6 +145,8 @@ The executable project covers Selenium navigation/selection/actions/assertions/d Common suite tags include `@all`, `@regression`, `@smoke`, `@browser`, and `@data`. Functional areas include `@navigation`, `@forms`, `@catalog`, `@mapping`, `@resources`, `@workflow`, `@keyboard`, `@dialogs`, and `@components`. +Controller/protocol migration checks must remain focused: use `@control-bridge` and/or `@step-override-bridge`, set `pkb_parallel=80` when practical, and do not run `@all` for Workbench isolation work. + ```bash mvn test -Dpkb_tags="@forms and @state-assertions" mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/dynamic-control-api.md b/src/main/resources/META-INF/pickleball/guidance/docs/dynamic-control-api.md index ab2890b3..d8e43c86 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/dynamic-control-api.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/dynamic-control-api.md @@ -1,6 +1,6 @@ # Dynamic Control API -Pickleball exposes a small core interception contract plus a separately organized `pickleball-control-api` source module for dynamic tooling. The control API classes are bundled into the main `tools.dscode:pickleball` artifact; consumers do not add a second Maven dependency. The source module is intentionally independent of MCP, Spring AI, GUIs, and process orchestration. +Pickleball exposes a small core interception contract plus a separately organized `pickleball-control-api` source module for dynamic tooling. The behavioral control API classes are bundled into the main `tools.dscode:pickleball` artifact; consumers do not add a second Maven dependency. Versioned wire records live separately in the JDK-only `pickleball-control-protocol` module so the Workbench controller never depends on behavioral runtime classes. ## Artifact and compatibility @@ -39,6 +39,8 @@ For backward compatibility, Pickleball may accept the former `PKB_STUDIO_BRIDGE_ Each participating consumer JVM binds to `127.0.0.1` on an operating-system-assigned port and writes a runtime descriptor into the session directory. Requests require the session bearer token and responses are marked `Cache-Control: no-store`. +The descriptor advertises the current and minimum-compatible protocol versions, capabilities, PID, Pickleball implementation version, and runtime code source. Workbench rejects incompatible capabilities/versions, a same-process worker, a runtime origin outside the synchronized consumer classpath, version drift, or a worker classpath containing the controller. It never falls back to executing a Workbench-bundled runtime. + The bridge keeps live operations on the real scenario thread through `ControlBridgeCoordinator`. This preserves access to thread-local Cucumber/Pickleball state, glue, browser, services, mappings, and other scenario resources. Bridge capabilities include: @@ -54,7 +56,7 @@ Bridge capabilities include: - semantic breakpoint management; - scenario-scoped Step Override management. -Workbench owns the controller-side bridge client. MCP and Swing access these capabilities through `WorkbenchServices` / `WorkbenchController`; they do not connect to the bridge independently or implement a second runtime. +Workbench owns the controller-side protocol client. MCP and Swing access these capabilities through `WorkbenchServices` / `WorkbenchController`; they do not connect to the bridge independently or implement a second runtime. Workbench imports only `tools.dscode.control.protocol.*`; bridge server/coordinator/bootstrap and conversion to runtime objects remain worker-side. ## Scenario targeting and finite pauses @@ -86,4 +88,4 @@ Worker-side compilation requires `javax.tools.JavaCompiler`. Workbench sends a J ## Architecture boundary -The control API and bridge intentionally remain independent of MCP, Spring, GUI frameworks, generic project IDE behavior, and build orchestration. Pickleball owns live execution semantics. Workbench is the external controller/adaptation layer. +The control API and bridge intentionally remain independent of MCP, Spring, GUI frameworks, generic project IDE behavior, and build orchestration. Pickleball owns live execution semantics. Workbench is the external controller/adaptation layer and its artifact contains no Pickleball core, behavioral control API, Cucumber, Selenium, or service runtime. The only shared code is the dependency-neutral wire protocol. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md b/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md index e3f8091c..00860eeb 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md @@ -15,7 +15,7 @@ A consumer normally needs the Pickleball test dependency and one runner extendin ```xml 21 - 2.1.5 + 2.1.9 @@ -83,6 +83,19 @@ Named profile definitions use the same shared/local idea: define shared profiles mvn test ``` +## Launch the matching Workbench + +The Pickleball dependency carries its version-matched, controller-only Workbench as an opaque nested executable. Launch it from the consumer test classpath; do not add or version a second Workbench dependency: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +The launcher extracts verified bytes beneath `.pickleball/workbench/controller//` and starts a separate controller JVM. Workbench then synchronizes the project and starts a second, consumer-owned worker JVM from the resolved test runtime. Core, Cucumber, Selenium, service behavior, mappings, and steps execute only in that worker. See [Pickleball Workbench](pickleball-workbench.md). + Filter normally with RunVars such as: ```bash diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index b6237d54..e0f5d466 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -1,20 +1,26 @@ # Pickleball Workbench -Pickleball Workbench is the separate executable companion for interactive Pickleball execution and investigation. It depends on the normal shaded/woven `tools.dscode:pickleball` artifact; normal Pickleball consumers do not depend on Workbench. +Pickleball Workbench is the external controller for interactive Pickleball execution and investigation. Its executable contains controller code, GUI/MCP adapters, synchronization support, JSON transport, and the neutral wire protocol—but no Pickleball core/runtime. Real execution occurs only in a separate consumer worker using the consumer project's compiled output and resolved test runtime. Workbench replaces the former Pickleball Studio application. The supported architecture is deliberately execution-oriented: project synchronization, a persistent consumer worker, live runtime control, Mapping, browser/service evidence, semantic breakpoints, Step Override authoring, lightweight non-Spring MCP stdio, and a Swing UI over the same service seam. ## Architecture -Dependency direction is strictly: +Source dependencies and distribution are strictly separated: ```text -pickleball-workbench -> pickleball +pickleball core/worker --------> JDK-only control protocol +pickleball-workbench ----------> JDK-only control protocol +published pickleball JAR ------> opaque Workbench executable bytes ``` +The distribution arrow is an assembly input, not a Workbench-to-core Java dependency. **Pickleball may contain Workbench; Workbench must not contain Pickleball.** Separate JVMs are required, but they are not sufficient: dependency graphs, class visibility, JAR entries, nested JARs, service providers, and runtime origins are checked too. + Pickleball owns scenario execution semantics, Cucumber integration, DynamicControl/Gherkin execution, Mapping, browser/service behavior, the consumer-side Control Bridge, semantic hooks/breakpoints, Step Overrides, and woven Cucumber/AspectJ behavior. -Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the controller-side bridge client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, the headless live-scenario presentation model, and the Swing adapter. +Workbench owns synchronization, `.pickleball/workbench/` disposable state, worker lifecycle, the protocol client, `WorkbenchLiveSession`, `WorkbenchServices` / `WorkbenchController`, MCP stdio, the headless live-scenario presentation model, and the Swing adapter. It does not import the worker entry point; it launches the protocol's class-name string on the captured consumer classpath. + +`pickleball-control-protocol` owns only immutable wire records, request/response envelopes, transport constants, capabilities, and version/minimum-version negotiation. Worker-side bridge server/coordinator/bootstrap and all translation to runtime operations remain in Pickleball core. MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. @@ -29,12 +35,55 @@ PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO Pickleball may accept `PKB_STUDIO_BRIDGE_*` as deprecated compatibility input aliases only. Workbench emits only the neutral names. -## Build and run +## Launch from a consumer project + +The normal `tools.dscode:pickleball:` test dependency already carries the matching controller at: -Build the executable companion: +```text +META-INF/pickleball/workbench/pickleball-workbench.jar +``` + +Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` ```powershell -.\gradlew.bat :pickleball-workbench:build +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +``` + +With no launcher arguments, `ui` and the current directory are selected automatically. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."` or `"-Dexec.args=mcp ."`. + +Gradle consumers can expose the same dependency-owned launcher without resolving a cache path or adding a Workbench dependency: + +```groovy +tasks.register('pickleballWorkbench', JavaExec) { + classpath = sourceSets.test.runtimeClasspath + mainClass = 'tools.dscode.launcher.PickleballWorkbenchLauncher' + args 'ui', projectDir.absolutePath +} +``` + +Run it with `./gradlew pickleballWorkbench` (or `gradlew.bat pickleballWorkbench`). The task uses the consumer's resolved test runtime only to locate the tiny launcher and nested bytes; actual controller code still starts in a separate `java -jar` process. + +The launcher reads the nested payload, limits its size, calculates SHA-256, and extracts it atomically to: + +```text +.pickleball/workbench/controller//pickleball-workbench.jar +``` + +It verifies existing/extracted bytes, starts `java -jar` in a new Workbench JVM, inherits stdio, and propagates non-zero exit status. The content-addressed path prevents a stale payload from silently replacing the version carried by the consumer dependency. + +## Maintainer build and direct run + +Build the standalone controller and strict isolation checks: + +```powershell +.\gradlew.bat :pickleball-workbench:build verifyStrictControllerIsolation ``` The executable is: @@ -43,7 +92,7 @@ The executable is: pickleball-workbench/build/libs/pickleball-workbench-.jar ``` -Synchronize a consumer project before starting a worker manually: +Synchronize a consumer project before starting a worker manually from repository output: ```powershell $workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" @@ -52,6 +101,8 @@ java -jar $workbenchJar sync ".\maven-consumer-project" Synchronization uses the selected project wrapper to establish compiled output and the effective test runtime classpath. `.pickleball/workbench/base/classes` is provenance only; the worker runs against the merged `.pickleball/workbench/live/classes` state plus captured external dependencies. +At worker connection time, Workbench requires a different PID, compatible protocol range and capabilities, a Pickleball code source that is exactly one captured consumer classpath entry, the synchronized Pickleball version (except explicit development output), and no Workbench controller artifact on the worker classpath. It fails clearly instead of falling back to a bundled runtime. + ## Swing UI Start Workbench for one consumer project: @@ -259,14 +310,18 @@ Workbench MCP and Swing intentionally do not expose a generic IDE or build syste ## Dependency and artifact checks -The Workbench build keeps the published-equivalent Pickleball boundary and verifies that: +The build proves the controller boundary with `verifyStrictControllerIsolation` and its component tasks: -- the Workbench executable contains the MCP adapter; -- normal Pickleball contains neither Workbench classes nor MCP SDK classes; -- Workbench does not resolve the unpublished `pickleball-control-api` project; -- separate unwoven Cucumber modules do not appear on the Workbench runtime; +- `pickleball-control-protocol` has no non-JDK dependency; +- the only Workbench project dependency is `pickleball-control-protocol`; +- the Workbench compile/runtime graph contains no root Pickleball, behavioral control API, Cucumber, Selenium, or REST-assured path; +- the Workbench executable contains its controller, GUI, MCP, protocol client, and runtime isolation guard; +- top-level and nested Workbench entries and service descriptors contain no core/worker implementation or nested Pickleball runtime; +- the published Workbench POM has no dependencies; - the MCP convenience artifact / Jackson 3 path is not used; -- the published Workbench POM still declares only `tools.dscode:pickleball`. +- the outer Pickleball JAR contains exactly one opaque Workbench payload whose bytes equal the standalone output; +- Workbench/MCP entries are not flattened into the outer runtime namespace; and +- the nested payload has the expected Workbench `Main-Class`. Report the executable size and resolved MCP SDK artifacts with: @@ -308,6 +363,12 @@ For the player state model and Swing/controller behavior: .\gradlew.bat :pickleball-workbench:test ``` +For protocol, dependency, nested-artifact, and process-boundary checks: + +```powershell +.\gradlew.bat verifyStrictControllerIsolation +``` + For shared controller/MCP behavior: ```powershell @@ -323,3 +384,12 @@ java -jar $workbenchJar sync ".\maven-consumer-project" java -jar $workbenchJar worker-check ".\maven-consumer-project" java -jar $workbenchJar live-check ".\maven-consumer-project" ``` + +For changed consumer bridge behavior, use only the affected focused tags—never `@all`—and use parallelism 80 where practical: + +```powershell +.\maven-consumer-project\mvnw.cmd -f maven-consumer-project\pom.xml -U test "-Dpkb_runvars.pkb_browser=CHROME_HEADLESS" "-Dpkb_runvars.pkb_parallel=80" "-Dpkb_runvars.pkb_tags=@control-bridge" +.\maven-consumer-project\mvnw.cmd -f maven-consumer-project\pom.xml -U test "-Dpkb_runvars.pkb_browser=CHROME_HEADLESS" "-Dpkb_runvars.pkb_parallel=80" "-Dpkb_runvars.pkb_tags=@step-override-bridge" +``` + +Run these invocations sequentially because the scenarios deliberately verify the process-global bridge bootstrap. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/step-overrides.md b/src/main/resources/META-INF/pickleball/guidance/docs/step-overrides.md index 0992d7cf..b0793a15 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/step-overrides.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/step-overrides.md @@ -96,4 +96,4 @@ The focused consumer tag is: @step-override ``` -Run it with the normal parallel consumer acceptance settings. Workbench `live-check` also compiles an override, executes override-only Gherkin, replaces the generated implementation, removes it, verifies fallback behavior, and confirms the same persistent worker context was retained. +For a bridge/protocol-only change, the smallest tag is `@step-override-bridge`; use `@step-override` when worker matching/compiler semantics changed too. Set `pkb_parallel=80` when practical and do not substitute `@all` for focused Workbench validation. Workbench `live-check` also compiles an override, executes override-only Gherkin, replaces the generated implementation, removes it, verifies fallback behavior, and confirms the same persistent worker context was retained. diff --git a/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java new file mode 100644 index 00000000..f52e2544 --- /dev/null +++ b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java @@ -0,0 +1,62 @@ +package tools.dscode.launcher; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PickleballWorkbenchLauncherTest { + @TempDir + Path tempDir; + + @Test + void extractionIsContentAddressedAndCommandAlwaysStartsASeparateJarProcess() throws Exception { + byte[] firstPayload = {1, 2, 3, 4}; + byte[] secondPayload = {1, 2, 3, 5}; + + Path first = PickleballWorkbenchLauncher.extractPayload(tempDir, firstPayload); + Path repeated = PickleballWorkbenchLauncher.extractPayload(tempDir, firstPayload); + Path second = PickleballWorkbenchLauncher.extractPayload(tempDir, secondPayload); + + assertEquals(first, repeated); + assertNotEquals(first, second); + assertArrayEquals(firstPayload, Files.readAllBytes(first)); + assertArrayEquals(secondPayload, Files.readAllBytes(second)); + + Files.write(first, new byte[]{9}); + Path repaired = PickleballWorkbenchLauncher.extractPayload(tempDir, firstPayload); + assertEquals(first, repaired); + assertArrayEquals(firstPayload, Files.readAllBytes(repaired)); + + List command = PickleballWorkbenchLauncher.command( + first, + new String[]{"ui", tempDir.toString()} + ); + assertTrue(command.get(0).endsWith(javaExecutableName())); + assertEquals("-jar", command.get(1)); + assertEquals(first.toAbsolutePath().normalize().toString(), command.get(2)); + assertEquals(List.of("ui", tempDir.toString()), command.subList(3, command.size())); + + String[] defaults = PickleballWorkbenchLauncher.normalizedArguments(new String[0]); + String[] implicitProject = PickleballWorkbenchLauncher.normalizedArguments( + new String[]{"mcp"} + ); + assertEquals("ui", defaults[0]); + assertEquals(Path.of("").toAbsolutePath().normalize().toString(), defaults[1]); + assertEquals("mcp", implicitProject[0]); + assertEquals(Path.of("").toAbsolutePath().normalize().toString(), implicitProject[1]); + } + + private static String javaExecutableName() { + return System.getProperty("os.name", "").toLowerCase().contains("win") + ? "java.exe" + : "java"; + } +} From ff7f23d4a79447f804ed17b6c5c027a2e221fb13 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Fri, 21 Aug 2026 11:03:57 -0700 Subject: [PATCH 04/15] updated workbench --- WORKBENCH-PLAYER-DROP-IN.md | 36 + build.gradle | 2 +- docs/pickleball-workbench-player.md | 111 ++ .../pickleball/ControlApiTestSteps.java | 47 + .../resources/features/control-bridge.feature | 3 + .../dscode/control/api/DynamicControl.java | 67 +- .../dscode/control/api/MappingControl.java | 78 +- .../control/protocol/ControlProtocol.java | 10 + .../WORKBENCH-PLAYER-CONTEXT.md | 51 + pickleball-workbench/build.gradle | 1 + .../workbench/player/LiveScenarioPlayer.java | 139 +- .../dscode/workbench/ui/WorkbenchFrame.java | 1371 ++++++++++------- .../workbench/ui/WorkbenchUiController.java | 160 +- .../player/LiveScenarioPlayerTest.java | 169 +- 14 files changed, 1511 insertions(+), 734 deletions(-) create mode 100644 WORKBENCH-PLAYER-DROP-IN.md create mode 100644 docs/pickleball-workbench-player.md create mode 100644 pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md diff --git a/WORKBENCH-PLAYER-DROP-IN.md b/WORKBENCH-PLAYER-DROP-IN.md new file mode 100644 index 00000000..5d9ec3bf --- /dev/null +++ b/WORKBENCH-PLAYER-DROP-IN.md @@ -0,0 +1,36 @@ +# Pickleball 2.1.9 Workbench Player Drop-in + +Extract this ZIP at the root of the `2.1.9` branch and allow the included files to replace matching paths. + +No build, publishing, dependency, Shadow JAR, launcher, Maven Central, or Workbench isolation configuration is changed. + +## What changes + +- Global Play always starts a fresh scenario run from the first executable step. +- Step Editor provides distinct **Step** and **From Here** play actions. +- **From Here** restarts into a fresh scenario context and runs from the selected step onward. +- Successful steps no longer retain checkmarks or gray executed styling; only the active execution line is marked. +- End-of-buffer remains `WAITING_FOR_STEP`. +- Enter inserts after the selected line and resumes a waiting player when the new step extends the active run. +- Step-only execution pauses automatic playback and uses the current live context. +- A working three-step consumer smoke scenario is preloaded. +- Full Gherkin keyword lines are parsed worker-side. +- Mapping becomes a current-ParsingMap NodeMap dropdown plus auto-saving JSON object editor. +- Focused `@control-bridge` acceptance coverage is extended. + +## Suggested validation after extraction + +```powershell +.\scripts\agent_validate.ps1 +.\gradlew.bat verifyStrictControllerIsolation :pickleball-workbench:test +.\gradlew.bat publishToMavenLocal +.\maven-consumer-project\mvnw.cmd -f maven-consumer-project\pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_parallel=80 -Dpkb_runvars.pkb_tags=@control-bridge +``` + +Run the repository index refresh after extraction if the agent contract check reports the index is stale: + +```powershell +python scripts\refresh_agent_index.py +``` + +Then launch the UI from the consumer project. Global Play and From Here intentionally restart the interactive worker to create fresh scenario state; Step Only reuses the current paused live context. diff --git a/build.gradle b/build.gradle index 5015172d..49e32624 100644 --- a/build.gradle +++ b/build.gradle @@ -340,7 +340,7 @@ tasks.register('verifyEmbeddedWorkbench') { dependsOn tasks.shadowJar, ':pickleball-workbench:verifyWorkbenchArtifact' doLast { - File outerFile = tasks.shadowJar.get().archiveFile.get().asFile + File outerFile = tasks.named('shadowJar').get().archiveFile.get().asFile File standaloneFile = standaloneWorkbenchJar.get().asFile byte[] standaloneBytes = standaloneFile.bytes diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md new file mode 100644 index 00000000..05438ea6 --- /dev/null +++ b/docs/pickleball-workbench-player.md @@ -0,0 +1,111 @@ +# Pickleball Workbench Live Player + +This document describes the live-player behavior implemented by the Workbench UI drop-in for the 2.1.9 branch. + +## Architecture boundary + +The Workbench distribution and process model is unchanged: + +```text +published pickleball JAR + -> embeds one opaque pickleball-workbench.jar + -> launcher extracts it and starts `java -jar` in a separate controller JVM + +Workbench controller JVM + -> controller/UI/MCP only + -> shares only pickleball-control-protocol wire classes + -> never loads Pickleball core, control API, consumer classes, Cucumber, Selenium, or REST-assured + +consumer worker JVM + -> runs from the synchronized consumer test-runtime classpath + -> owns Pickleball, Cucumber, DynamicControl, Mapping/ParsingMap/NodeMap, browser, and service behavior +``` + +The player and Mapping editor do not change this boundary. + +## Live Scenario Editor + +The left-side editor is a player-oriented scenario buffer. There is no separately user-controlled playhead. The user's selected line is the editing/navigation target; the execution cursor is transient and exists only while a run is active. + +The initial buffer contains a small working smoke scenario: + +```gherkin +Feature: Workbench Live Scenario + +Scenario: Quick player smoke test + Given ---workbench-player-smoke-1 + And ---workbench-player-smoke-2 + Then ---workbench-player-smoke-3 +``` + +Pickleball core already owns the `---...` marker definition as a guaranteed no-op, so this smoke scenario does not depend on consumer-specific glue. + +### Controls + +- The global **Play** button always creates a fresh interactive scenario context and runs from the first executable scenario step. +- **Pause** prevents the next automatic step from starting. An already in-flight step is allowed to finish. +- **Stop** stops automatic advancement but does not kill the consumer worker. Worker lifecycle remains under **Session**. +- The Step Editor has two execution actions: + - **Step** executes only the Step Editor text against the current paused live context and leaves automatic scenario playback paused. + - **From Here** creates a fresh interactive scenario context and treats the selected executable step as the first step of that run, then continues through the remaining scenario-buffer steps. +- Fresh scenario playback restarts the consumer worker so browser, Mapping, service, and other side effects from a previous run do not leak into a new **Play** or **From Here** run. +- Reaching the end while playing changes the player to **Waiting for step** rather than stopping. Adding another step while waiting executes it immediately in the current live context. +- **Enter** inserts a new step after the selected line. With no selection, it appends after the last executable scenario step. +- **Ctrl+Enter** updates the selected executable step. Steps remain editable after earlier runs because a later **Play** or **From Here** establishes a fresh scenario context. +- The editor shows only a transient `▶` on the current/next execution line. Successful lines do not retain checkmarks or become grayed out. + +Selection does not mutate runtime state. Choosing **From Here** is the explicit action that starts a new fresh run from that selected step. + +## Full Gherkin line execution + +The Workbench sends the displayed line unchanged over the existing `execute_step` bridge operation. + +If the input starts with `Given`, `When`, `Then`, `And`, `But`, or `*`, `DynamicControl` parses that one line using Pickleball/Cucumber inside the consumer worker and executes the resulting detached step text. + +The controller does not strip keywords or load a Gherkin parser. + +Historical raw detached-step input remains supported. + +## Mapping tab + +The Mapping tab is an object editor rather than a get/put/resolve form. + +It contains: + +1. A **NodeMap** dropdown populated from the actual NodeMaps in the current worker-side `ParsingMap`. +2. One editable JSON text area containing the materialized root object of the selected NodeMap. + +The dropdown is populated through the existing Mapping snapshot contract using a reserved neutral protocol reference. The worker resolves the reserved reference to a catalog generated from the current `ParsingMap`; the Workbench sees only neutral snapshot data. + +Each catalog entry uses a second reserved reference that resolves back to the same current NodeMap through `MappingControl`. Ordinary NodeMap references continue to behave unchanged. + +### Editing + +For an ordinary restorable NodeMap: + +- change scalar values directly; +- add or delete properties; +- add or edit nested objects; +- add or edit arrays; +- assign an object as a value by entering its JSON object structure. + +After a short debounce, valid JSON is restored through the existing `mapping_restore` bridge operation. Invalid intermediate JSON is not sent to the worker. + +NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only, preserving the existing restore safety rule. + +## Focused validation + +The included consumer `@control-bridge` scenario verifies: + +- a full `Given CONTROL API TEST STEP` line is parsed in the consumer worker and normalized to the existing step text; +- the current ParsingMap catalog contains at least one NodeMap; +- a catalog reference resolves back to a live NodeMap. + +Workbench changes should continue to use the repository's focused validation policy: + +```powershell +.\gradlew.bat verifyStrictControllerIsolation :pickleball-workbench:test +.\maven-consumer-project\mvnw.cmd -f maven-consumer-project\pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_parallel=80 -Dpkb_runvars.pkb_tags=@control-bridge +``` + +Do not use `@all` as Workbench migration validation. diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/ControlApiTestSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/ControlApiTestSteps.java index 0b4a4960..f6759dcf 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/ControlApiTestSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/ControlApiTestSteps.java @@ -1,6 +1,10 @@ package com.example.pickleball; +import com.fasterxml.jackson.databind.JsonNode; import io.cucumber.java.en.Given; +import tools.dscode.control.api.DynamicControl; +import tools.dscode.control.api.MappingControl; +import tools.dscode.control.protocol.ControlProtocol; public class ControlApiTestSteps { private static int invocationCount; @@ -16,6 +20,49 @@ public void controlApiFailingTestStep() { throw new ExpectedControlFailure(); } + /** + * Focused acceptance check for the Workbench player contract. Gherkin parsing + * and current ParsingMap discovery must both remain inside the consumer worker. + */ + @Given("^VERIFY WORKBENCH PLAYER RUNTIME SUPPORT$") + public void verifyWorkbenchPlayerRuntimeSupport() { + var created = DynamicControl.createStep("Given CONTROL API TEST STEP"); + if (!created.successful()) { + throw new AssertionError( + "Full Gherkin live step was not accepted: " + + (created.error() == null ? created.status() : created.error().message()) + ); + } + if (!"CONTROL API TEST STEP".equals(created.value().getStepText())) { + throw new AssertionError( + "Worker did not normalize the Gherkin keyword before detached execution." + ); + } + + var catalog = MappingControl.currentNodeMap( + ControlProtocol.CURRENT_NODE_MAP_CATALOG_REFERENCE + ); + if (!catalog.successful()) { + throw new AssertionError( + "Current ParsingMap catalog was unavailable: " + + (catalog.error() == null ? catalog.status() : catalog.error().message()) + ); + } + + JsonNode maps = catalog.value().getRoot().get("maps"); + if (maps == null || !maps.isArray() || maps.isEmpty()) { + throw new AssertionError("Current ParsingMap catalog did not expose any NodeMaps."); + } + + String firstReference = maps.get(0).path("reference").asText(); + var currentMap = MappingControl.currentNodeMap(firstReference); + if (!currentMap.successful() || currentMap.value() == null) { + throw new AssertionError( + "Catalog NodeMap reference could not be resolved: " + firstReference + ); + } + } + static void reset() { invocationCount = 0; rawStackTracePrintCount = 0; diff --git a/maven-consumer-project/src/test/resources/features/control-bridge.feature b/maven-consumer-project/src/test/resources/features/control-bridge.feature index 14f32440..0e1eb3a0 100644 --- a/maven-consumer-project/src/test/resources/features/control-bridge.feature +++ b/maven-consumer-project/src/test/resources/features/control-bridge.feature @@ -5,3 +5,6 @@ Feature: Pickleball Workbench consumer-worker control bridge Given BEGIN CONTROL BRIDGE IPC TEST And CONTROL BRIDGE IPC SYNC POINT And VERIFY CONTROL BRIDGE IPC TEST + + Scenario: Player Gherkin and current ParsingMap contracts remain worker-owned + Given VERIFY WORKBENCH PLAYER RUNTIME SUPPORT diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/api/DynamicControl.java b/pickleball-control-api/src/main/java/tools/dscode/control/api/DynamicControl.java index 26e231d0..3e5f2841 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/api/DynamicControl.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/api/DynamicControl.java @@ -1,6 +1,8 @@ package tools.dscode.control.api; +import io.cucumber.core.gherkin.Feature; import io.cucumber.core.gherkin.Pickle; +import io.cucumber.core.gherkin.Step; import io.cucumber.core.runner.CurrentScenarioState; import io.cucumber.core.runner.GlobalState; import io.cucumber.core.runner.StepExtension; @@ -29,11 +31,22 @@ public static ControlCallResult createStep(String text) { return createStep(text, ""); } + /** + * Creates a detached Pickleball step. + * + *

Controller callers may supply either the historical raw step text or one + * complete Gherkin step line such as {@code Given CONTROL API TEST STEP}. + * Gherkin parsing deliberately happens here in the consumer worker, never in + * Workbench, so the controller remains independent of Cucumber/Pickleball.

+ */ public static ControlCallResult createStep(String text, String argument) { if (GlobalState.getCurrentScenarioState() == null || GlobalState.getTestCase() == null) { return ControlCallResult.unavailable("Dynamic step creation requires an active Pickleball test context."); } - return attempt(() -> getCustomStep(text, argument)); + return attempt(() -> { + DynamicStepSpec normalized = normalizeWorkbenchStep(text, argument); + return getCustomStep(normalized.text(), normalized.argument()); + }); } /** Creates a detached step with an exact caller-defined mapping source set. */ @@ -58,7 +71,6 @@ public static ControlCallResult createStep( }); } - /** Creates every requested step and keeps going after individual failures. */ public static List> createSteps(List steps) { if (steps == null || steps.isEmpty()) { @@ -177,7 +189,6 @@ public static List> executeSteps( return List.copyOf(results); } - /** Executes a parsed Cucumber scenario/background-expanded Pickle as detached steps. */ public static List> executePickle(Pickle pickle) { if (pickle == null) { @@ -254,7 +265,6 @@ public static ControlCallResult addChild(StepExtension parent, St }); } - public static ControlCallResult> addChildren( StepExtension parent, List children @@ -309,6 +319,55 @@ public static ControlCallResult currentParsingMap() { return attempt(() -> getRunningParsingMap()); } + private static DynamicStepSpec normalizeWorkbenchStep(String text, String argument) { + String raw = text == null ? "" : text; + String trimmed = raw.strip(); + if (!looksLikeGherkinStep(trimmed)) { + return new DynamicStepSpec(raw, argument); + } + + String source = """ + Feature: Workbench detached step + Scenario: Live step + %s + """.formatted(trimmed); + ControlCallResult parsed = GherkinControl.parseFeature(source); + if (!parsed.successful()) { + String message = parsed.error() == null + ? "Could not parse the supplied Gherkin step." + : parsed.error().message(); + throw new IllegalArgumentException(message); + } + + List pickles = GherkinControl.scenarios(parsed.value()); + if (pickles.size() != 1) { + throw new IllegalArgumentException("A live Workbench command must contain exactly one Gherkin step."); + } + List steps = GherkinControl.steps(pickles.getFirst()); + if (steps.size() != 1) { + throw new IllegalArgumentException("A live Workbench command must contain exactly one Gherkin step."); + } + + Step step = steps.getFirst(); + String suppliedArgument = argument == null ? "" : argument; + String parsedArgument = GherkinControl.argumentText(step); + return new DynamicStepSpec( + step.getText(), + suppliedArgument.isBlank() ? parsedArgument : suppliedArgument + ); + } + + private static boolean looksLikeGherkinStep(String text) { + return startsWithAny(text, "Given ", "When ", "Then ", "And ", "But ", "* "); + } + + private static boolean startsWithAny(String value, String... prefixes) { + for (String prefix : prefixes) { + if (value.startsWith(prefix)) return true; + } + return false; + } + private static ControlCallResult attempt(Supplier action) { Objects.requireNonNull(action, "action"); try { diff --git a/pickleball-control-api/src/main/java/tools/dscode/control/api/MappingControl.java b/pickleball-control-api/src/main/java/tools/dscode/control/api/MappingControl.java index 6bce8b69..454b1a4b 100644 --- a/pickleball-control-api/src/main/java/tools/dscode/control/api/MappingControl.java +++ b/pickleball-control-api/src/main/java/tools/dscode/control/api/MappingControl.java @@ -1,5 +1,6 @@ package tools.dscode.control.api; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.cucumber.core.runner.GlobalState; import tools.dscode.common.mappings.GlobalMappings; @@ -7,10 +8,12 @@ import tools.dscode.common.mappings.MappingProcessor; import tools.dscode.common.mappings.NodeMap; import tools.dscode.common.mappings.ParsingMap; +import tools.dscode.control.protocol.ControlProtocol; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; +import java.util.IdentityHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -89,11 +92,24 @@ public static ControlCallResult current() { return attempt(ParsingMap::getRunningParsingMap); } + /** + * Resolves normal Pickleball NodeMap references plus the two neutral Workbench + * references defined in {@link ControlProtocol}. The Workbench references are + * intentionally resolved here, inside the consumer worker, so controller code + * never needs ParsingMap/NodeMap classes or a shared execution classpath. + */ public static ControlCallResult currentNodeMap(String reference) { if (reference == null || reference.isBlank()) { return ControlCallResult.unavailable("NodeMap reference must not be blank."); } - return attempt(() -> NodeMap.getNodeMap(reference)); + String normalized = reference.trim(); + if (ControlProtocol.CURRENT_NODE_MAP_CATALOG_REFERENCE.equals(normalized)) { + return attempt(MappingControl::currentNodeMapCatalog); + } + if (normalized.startsWith(ControlProtocol.CURRENT_NODE_MAP_REFERENCE_PREFIX)) { + return attempt(() -> currentNodeMapByIndex(normalized)); + } + return attempt(() -> NodeMap.getNodeMap(normalized)); } public static ControlCallResult currentNodeMapCopy(String reference) { @@ -186,14 +202,14 @@ public static ControlCallResult withCurrent( public static ControlCallResult resolveText(MappingContext context, String input) { if (context == null) { - return ControlCallResult.unavailable("mapping context must not be null"); + return ControlCallResult.unavailable("mappingContext must not be null"); } return attempt(() -> context.parsingMap().resolveWholeText(input)); } public static ControlCallResult resolveValue(MappingContext context, String input) { if (context == null) { - return ControlCallResult.unavailable("mapping context must not be null"); + return ControlCallResult.unavailable("mappingContext must not be null"); } return attempt(() -> context.parsingMap().resolveWholeValue(input)); } @@ -354,6 +370,62 @@ private static List distinctOrder(List maps) return List.copyOf(order); } + private static NodeMap currentNodeMapCatalog() { + List maps = distinctCurrentNodeMaps(); + ObjectNode root = MAPPER.createObjectNode(); + ArrayNode entries = root.putArray("maps"); + for (int index = 0; index < maps.size(); index++) { + NodeMap map = maps.get(index); + ObjectNode entry = entries.addObject(); + entry.put("reference", ControlProtocol.CURRENT_NODE_MAP_REFERENCE_PREFIX + index); + entry.put("label", map.getMapType().name()); + entry.put("mapType", map.getMapType().name()); + entry.put("mapClass", map.getClass().getName()); + entry.put("restorable", map.getClass() == NodeMap.class); + ArrayNode sources = entry.putArray("dataSources"); + map.getDataSources().stream() + .map(Enum::name) + .sorted() + .forEach(sources::add); + } + + /* + * Anonymous subclass intentionally makes the catalog inspection-only. + * The bridge's existing snapshot logic marks only exact NodeMap instances + * as restorable. + */ + return new NodeMap(MapConfigurations.MapType.DEFAULT, root) { }; + } + + private static NodeMap currentNodeMapByIndex(String reference) { + String indexText = reference.substring(ControlProtocol.CURRENT_NODE_MAP_REFERENCE_PREFIX.length()); + int index; + try { + index = Integer.parseInt(indexText); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("Invalid current NodeMap reference: " + reference, failure); + } + List maps = distinctCurrentNodeMaps(); + if (index < 0 || index >= maps.size()) { + throw new IllegalArgumentException( + "Current NodeMap reference is no longer available: " + reference + ); + } + return maps.get(index); + } + + private static List distinctCurrentNodeMaps() { + ParsingMap parsingMap = ParsingMap.getRunningParsingMap(); + Set seen = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + List maps = new ArrayList<>(); + for (NodeMap map : parsingMap.getMapsForResolution()) { + if (map != null && seen.add(map)) { + maps.add(map); + } + } + return List.copyOf(maps); + } + private static NodeMap requireMap(NodeMap map) { return Objects.requireNonNull(map, "map"); } diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java index 210ed3d6..d26970d8 100644 --- a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java @@ -13,6 +13,16 @@ public final class ControlProtocol { public static final String EMBEDDED_WORKBENCH_RESOURCE = "META-INF/pickleball/workbench/pickleball-workbench.jar"; + /* + * Reserved neutral references used over the existing Mapping snapshot/restore + * contract. The worker resolves these against the currently running ParsingMap; + * the Workbench never imports ParsingMap or NodeMap classes. + */ + public static final String CURRENT_NODE_MAP_CATALOG_REFERENCE = + "__pickleball_workbench_current_nodemap_catalog__"; + public static final String CURRENT_NODE_MAP_REFERENCE_PREFIX = + "__pickleball_workbench_current_nodemap__:"; + public static final String SESSION_DIRECTORY_ENV = "PKB_CONTROL_BRIDGE_SESSION_DIR"; public static final String SESSION_ID_ENV = "PKB_CONTROL_BRIDGE_SESSION_ID"; public static final String SESSION_TOKEN_ENV = "PKB_CONTROL_BRIDGE_TOKEN"; diff --git a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md new file mode 100644 index 00000000..a9fa9670 --- /dev/null +++ b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md @@ -0,0 +1,51 @@ +# Workbench Player Context + +Root `AGENTS.md` and `pickleball-workbench/AGENTS.md` remain authoritative for isolation, synchronization, MCP, and worker-lifecycle rules. + +This file records the live-player behavior added on top of those unchanged boundaries. + +## Player contract + +`LiveScenarioPlayer` is presentation/buffer state only. Pickleball execution remains in the consumer worker. + +There is no user-editable playhead. Selection is the editor/navigation target; the execution cursor is internal and transient. + +The global Play action always starts from the first executable buffer step in a fresh interactive worker context. The Step Editor exposes two distinct execution actions: + +- **Step**: execute only the editor text in the current paused live context and leave automatic playback paused. +- **From Here**: restart into a fresh interactive scenario context, use the selected executable step as the first step of the run, and continue through the remaining buffer steps. + +Fresh Play/From Here runs restart the worker so prior browser, Mapping, service, or other side effects do not masquerade as the beginning of a scenario. Protocol-mismatched synchronized state still triggers the existing one-time resynchronization retry. + +Pause stops advancement after any current in-flight command. Stop stops automatic player advancement but does not imply runtime rewind and does not terminate the worker. + +At end-of-buffer, automatic playback remains `WAITING_FOR_STEP`. Enter inserts after the selected line (or after the last executable step when nothing is selected); adding a step while waiting resumes execution. Ctrl+Enter updates the selected executable step regardless of whether it was executed in an earlier run. + +Successful execution is not retained as permanent checkmarks or gray styling. The scenario editor shows only a transient current-execution marker; failure detail remains available through selection, status, and Terminal output. + +The Workbench sends displayed Gherkin unchanged. Full `Given`/`When`/`Then`/`And`/`But`/`*` interpretation is implemented in worker-side `DynamicControl`; never move keyword stripping or Cucumber parsing into Workbench. + +## Mapping editor contract + +The primary Mapping tab has no get/put/resolve form. It is: + +- one current-ParsingMap NodeMap dropdown; +- one JSON object editor for the selected NodeMap root. + +Current NodeMaps are discovered worker-side through `MappingControl` using the reserved neutral references in `ControlProtocol`. Workbench sees only `ControlBridgeMappingSnapshot` data and must never import `ParsingMap`, `NodeMap`, or other Pickleball runtime classes. + +Valid JSON edits are applied by constructing a replacement `ControlBridgeMappingSnapshot` with the original identity/type/class/data-source metadata and calling the existing `mappingRestore` service. + +Do not weaken the existing rule that only exact ordinary `NodeMap` instances are restorable. + +## Distribution invariant + +This feature must not change the existing distribution graph: + +```text +pickleball core/worker --------> pickleball-control-protocol +pickleball-workbench ----------> pickleball-control-protocol +published pickleball JAR ------> opaque completed Workbench JAR bytes +``` + +Pickleball may contain Workbench; Workbench must not contain Pickleball. diff --git a/pickleball-workbench/build.gradle b/pickleball-workbench/build.gradle index 0cb79bfe..af1aaff3 100644 --- a/pickleball-workbench/build.gradle +++ b/pickleball-workbench/build.gradle @@ -37,6 +37,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.13.4' } application { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java index baca65ac..bff40df3 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java @@ -9,8 +9,9 @@ /** * Headless presentation model for the Workbench live scenario buffer. * - *

This class owns only interactive-buffer state. It does not parse or execute - * Pickleball steps, mutate Mapping state, or implement runtime rewind semantics.

+ *

The editor selection is the user's navigation model. The execution cursor + * is internal and exists only while a run is active; it is not a separately + * editable playhead.

*/ public final class LiveScenarioPlayer { public enum State { @@ -28,18 +29,10 @@ public enum LineType { TEXT } - public enum ExecutionStatus { - NONE, - PENDING, - EXECUTED, - FAILED - } - - public record Line(long id, String text, LineType type, ExecutionStatus executionStatus) { + public record Line(long id, String text, LineType type) { public Line { Objects.requireNonNull(text, "text"); Objects.requireNonNull(type, "type"); - Objects.requireNonNull(executionStatus, "executionStatus"); } public boolean executable() { @@ -50,7 +43,7 @@ public boolean executable() { private final List lines = new ArrayList<>(); private long nextId = 1; private Long selectedId; - private int playheadIndex; + private int executionIndex; private State state = State.STOPPED; public LiveScenarioPlayer(List initialLines) { @@ -59,16 +52,20 @@ public LiveScenarioPlayer(List initialLines) { addInitialLine(text == null ? "" : text); } } - playheadIndex = findNextExecutableIndex(0); + executionIndex = findNextExecutableIndex(0); } + /** A consumer-independent Pickleball-core smoke scenario. */ public static LiveScenarioPlayer interactiveBuffer() { return new LiveScenarioPlayer(List.of( "Feature: Workbench Live Scenario", "", - "Scenario: Interactive session", + "Scenario: Quick player smoke test", + " Given ---workbench-player-smoke-1", + " And ---workbench-player-smoke-2", + " Then ---workbench-player-smoke-3", "", - "# Enter a live Gherkin step in the Step Editor below." + "# Global Play starts fresh from the first step. Select a step for From Here." )); } @@ -90,16 +87,11 @@ public Optional selectedLine() { } public Optional nextStep() { - if (playheadIndex >= lines.size()) return Optional.empty(); - Line line = lines.get(playheadIndex); + if (executionIndex >= lines.size()) return Optional.empty(); + Line line = lines.get(executionIndex); return line.executable() ? Optional.of(line) : Optional.empty(); } - /** Returns the display index of the next executable step, or {@code lines().size()} at end-of-buffer. */ - public int playheadIndex() { - return playheadIndex; - } - public void select(long id) { requireLineIndex(id); selectedId = id; @@ -109,42 +101,57 @@ public void clearSelection() { selectedId = null; } + /** Starts a new buffer run at the first executable step. */ + public void startFromBeginning() { + executionIndex = findNextExecutableIndex(0); + state = executionIndex < lines.size() ? State.RUNNING : State.WAITING_FOR_STEP; + } + + /** Starts a new buffer run at the selected executable step. */ + public void startFromSelectedStep() { + Line selected = selectedLine().orElseThrow(() -> + new IllegalStateException("Select a scenario step to run from here.")); + if (!selected.executable()) { + throw new IllegalStateException("Select an executable scenario step to run from here."); + } + executionIndex = requireLineIndex(selected.id()); + state = State.RUNNING; + } + /** - * Inserts a new live command at the playhead insertion point. - * The inserted line receives a stable id and becomes the next executable step. + * Inserts a new command directly after the selected line. With no selection, + * it is appended after the last executable scenario step. */ public Line insertStep(String text) { String stepText = requiredText(text, "Step"); - int insertAt = Math.min(playheadIndex, lines.size()); - Line inserted = new Line(nextId++, stepText, LineType.STEP, ExecutionStatus.PENDING); + int insertAt = insertionIndex(); + int appendAt = insertionAfterLastExecutable(); + Line inserted = new Line(nextId++, stepText, LineType.STEP); lines.add(insertAt, inserted); - playheadIndex = insertAt; - if (state == State.WAITING_FOR_STEP) state = State.RUNNING; + + if (state == State.WAITING_FOR_STEP && insertAt == appendAt) { + executionIndex = insertAt; + state = State.RUNNING; + } else if (insertAt < executionIndex) { + executionIndex++; + } return inserted; } - /** - * Updates the selected pending buffer step while preserving its durable id. - * Already executed/failed steps are intentionally not editable because this - * presentation model does not imply runtime rewind or side-effect rollback. - */ + /** Updates the selected executable step while preserving its stable id. */ public Line updateSelectedStep(String text) { String stepText = requiredText(text, "Step"); Line selected = selectedLine().orElseThrow(() -> - new IllegalStateException("Select an executable pending step to update.")); - if (!selected.executable() || selected.executionStatus() != ExecutionStatus.PENDING) { - throw new IllegalStateException("Only pending executable steps can be updated."); + new IllegalStateException("Select an executable step to update.")); + if (!selected.executable()) { + throw new IllegalStateException("Only executable scenario steps can be updated."); } int index = requireLineIndex(selected.id()); - Line updated = new Line(selected.id(), stepText, LineType.STEP, ExecutionStatus.PENDING); + Line updated = new Line(selected.id(), stepText, LineType.STEP); lines.set(index, updated); return updated; } - public void play() { - state = nextStep().isPresent() ? State.RUNNING : State.WAITING_FOR_STEP; - } - public void pause() { if (state == State.RUNNING || state == State.WAITING_FOR_STEP) { state = State.PAUSED; @@ -155,52 +162,42 @@ public void stop() { state = State.STOPPED; } - /** Isolated execution always leaves the main live player paused. */ + /** Step-only execution always leaves automatic scenario playback paused. */ public void pauseForIsolatedExecution() { state = State.PAUSED; } - /** Marks the current playhead step executed and advances to the next executable buffer line. */ + /** Advances a successful run to the next executable line. */ public void markCurrentStepExecuted(long stepId) { int index = requireCurrentStep(stepId); - Line current = lines.get(index); - lines.set(index, new Line(current.id(), current.text(), current.type(), ExecutionStatus.EXECUTED)); - playheadIndex = findNextExecutableIndex(index + 1); - if (state == State.RUNNING && playheadIndex >= lines.size()) { + executionIndex = findNextExecutableIndex(index + 1); + if (state == State.RUNNING && executionIndex >= lines.size()) { state = State.WAITING_FOR_STEP; } } - /** Marks the current playhead step failed and pauses without advancing it. */ + /** Leaves a failed run paused on its failed line. */ public void markCurrentStepFailed(long stepId) { - int index = requireCurrentStep(stepId); - Line current = lines.get(index); - lines.set(index, new Line(current.id(), current.text(), current.type(), ExecutionStatus.FAILED)); - playheadIndex = index; + executionIndex = requireCurrentStep(stepId); state = State.PAUSED; } - /** Navigation only. Does not reset execution status or claim to undo runtime side effects. */ - public void movePlayheadToFirstStep() { - playheadIndex = findNextExecutableIndex(0); + private int insertionIndex() { + if (selectedId != null) { + return requireLineIndex(selectedId) + 1; + } + return insertionAfterLastExecutable(); } - /** Navigation only. Does not reset execution status or claim to undo runtime side effects. */ - public void movePlayheadToPreviousStep() { - int from = Math.min(playheadIndex - 1, lines.size() - 1); - for (int i = from; i >= 0; i--) { - if (lines.get(i).executable()) { - playheadIndex = i; - return; - } + private int insertionAfterLastExecutable() { + for (int i = lines.size() - 1; i >= 0; i--) { + if (lines.get(i).executable()) return i + 1; } - movePlayheadToFirstStep(); + return lines.size(); } private void addInitialLine(String text) { - LineType type = classify(text); - ExecutionStatus status = type == LineType.STEP ? ExecutionStatus.PENDING : ExecutionStatus.NONE; - lines.add(new Line(nextId++, text, type, status)); + lines.add(new Line(nextId++, text, classify(text))); } private int findNextExecutableIndex(int from) { @@ -211,11 +208,11 @@ private int findNextExecutableIndex(int from) { } private int requireCurrentStep(long id) { - if (playheadIndex >= lines.size() || !lines.get(playheadIndex).executable() - || lines.get(playheadIndex).id() != id) { - throw new IllegalStateException("Step " + id + " is not the current playhead step."); + if (executionIndex >= lines.size() || !lines.get(executionIndex).executable() + || lines.get(executionIndex).id() != id) { + throw new IllegalStateException("Step " + id + " is not the current execution step."); } - return playheadIndex; + return executionIndex; } private int requireLineIndex(long id) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index 19eb60a3..431dc1e6 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -1,32 +1,44 @@ package tools.dscode.workbench.ui; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; import tools.dscode.workbench.player.LiveScenarioPlayer; import javax.swing.*; import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; import java.awt.*; import java.awt.event.KeyEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.function.Consumer; import java.util.function.Supplier; /** Player-style Swing presentation adapter over the shared Workbench service seam. */ final class WorkbenchFrame extends JFrame { + private static final int MAPPING_SAVE_DELAY_MS = 650; + private final WorkbenchUiController controller; private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + private final ObjectMapper json = new ObjectMapper(); private final DefaultListModel scenarioModel = new DefaultListModel<>(); private final JList scenarioList = new JList<>(scenarioModel); private final JTextField stepText = new JTextField(); - private final JButton firstButton = playerButton("⏮", "First step (navigation only; does not undo runtime side effects)"); - private final JButton backButton = playerButton("◀", "Previous step (navigation only; does not undo runtime side effects)"); - private final JButton playButton = playerButton("▶", "Phase 1 player-state control; runtime run loop is added in Phase 2"); - private final JButton pauseButton = playerButton("⏸", "Pause player advancement at the next safe boundary"); - private final JButton playerStopButton = playerButton("■", "Stop the live player state"); - private final JButton isolatedStepButton = smallPlayerButton("▶", "Execute the Step Editor text in isolation"); + private final JButton playButton = playerButton("▶", "Run the scenario from the first step in a fresh scenario context"); + private final JButton pauseButton = playerButton("⏸", "Pause after the current in-flight step"); + private final JButton playerStopButton = playerButton("■", "Stop automatic scenario advancement"); + private final JButton stepOnlyButton = + smallPlayerButton("▶ Step", "Execute only the Step Editor text in the current paused scenario context"); + private final JButton fromHereButton = + smallPlayerButton("▶ From Here", "Start a fresh scenario context and run from the selected step"); private final JLabel projectLabel = new JLabel("Project: loading..."); private final JLabel readinessLabel = new JLabel("Loading status..."); @@ -39,57 +51,30 @@ final class WorkbenchFrame extends JFrame { private final JMenuItem restartItem = new JMenuItem("Restart Worker"); private final JMenuItem stopItem = new JMenuItem("Stop Worker"); - private final JButton mappingGetButton = new JButton("Get"); - private final JButton mappingPutButton = new JButton("Put"); - private final JButton mappingResolveButton = new JButton("Resolve"); - private final JComboBox nodeMapSelector = new JComboBox<>(); - private final JTextField mappingReference = new JTextField("OVERRIDE"); - private final JTextField mappingKey = new JTextField("workbenchLiveValue"); - private final JTextField mappingValue = new JTextField("first"); - private final JTextField mappingInput = new JTextField(""); - private final JTextArea mappingOutput = outputArea(); + private final JComboBox nodeMapSelector = + new JComboBox<>(); + private final JTextArea mappingEditor = new JTextArea(); + private final JLabel mappingStatus = new JLabel("Start the live worker to inspect Mapping."); + private final Timer mappingSaveTimer = new Timer( + MAPPING_SAVE_DELAY_MS, + event -> saveEditedMapping() + ); private final JTextArea terminalArea = outputArea(); - private final JTextArea statusArea = outputArea(); - private final JButton eventsRefreshButton = new JButton("Refresh Events"); - private final JTextArea eventsArea = outputArea(); - - private final JButton overrideCompileButton = new JButton("Compile / Replace"); - private final JButton overrideRefreshButton = new JButton("Refresh List"); - private final JButton overrideRemoveButton = new JButton("Remove ID"); - private final JButton overrideClearButton = new JButton("Clear All"); - private final JTextField overrideId = new JTextField("workbench-ui-generated"); - private final JTextField overrideRegex = new JTextField("^WORKBENCH UI OVERRIDE ([A-Za-z]+)$"); - private final JTextArea overrideSource = new JTextArea(defaultOverrideSource(), 16, 70); - private final JTextArea overrideOutput = outputArea(); - private final JTextArea overrideList = outputArea(); - - private final JButton browserPageButton = new JButton("Read Page"); - private final JButton browserScreenshotButton = new JButton("Capture Screenshot"); - private final JTextArea browserOutput = outputArea(); - private final JTabbedPane browserEvidenceTabs = new JTabbedPane(); - private final JLabel screenshotLabel = new JLabel("No screenshot captured.", SwingConstants.CENTER); - private final JButton serviceCallButton = new JButton("Execute Service Call"); - private final JTextField serviceSelector = new JTextField("%health-full-url"); - private final JTextArea serviceOutput = outputArea(); - - private final JButton breakpointAddButton = new JButton("Add"); - private final JButton breakpointRefreshButton = new JButton("Refresh List"); - private final JButton breakpointRemoveButton = new JButton("Remove ID"); - private final JButton breakpointClearButton = new JButton("Clear All"); - private final JTextField breakpointId = new JTextField(); - private final JTextField breakpointHook = new JTextField("BEFORE_STEP"); - private final JTextField breakpointSignature = new JTextField(); - private final JTextField breakpointStep = new JTextField("CONTROL API TEST STEP"); - private final JTextField breakpointPhrase = new JTextField(); - private final JCheckBox breakpointOneShot = new JCheckBox("One shot", true); - private final JTextField breakpointLease = new JTextField("120"); - private final JTextArea breakpointOutput = outputArea(); - private final JTextArea breakpointList = outputArea(); - private WorkbenchUiController.State lastState; - private JDialog advancedDialog; + private ControlBridgeMappingSnapshot loadedMapping; + private boolean loadingMapping; + private boolean refreshingCatalog; + private boolean mappingSaveBusy; + private long mappingEditGeneration; + + private boolean playbackPreparing; + private boolean playbackBusy; + private Long executingStepId; + private boolean pendingFreshRun; + private Long pendingFreshRunStepId; + private String pendingIsolatedStep; private boolean syncingScenarioSelection; private boolean closing; @@ -97,6 +82,8 @@ final class WorkbenchFrame extends JFrame { super("Pickleball Workbench"); this.controller = controller; + mappingSaveTimer.setRepeats(false); + setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); setMinimumSize(new Dimension(1100, 720)); setSize(1480, 900); @@ -107,17 +94,22 @@ final class WorkbenchFrame extends JFrame { root.setBorder(new EmptyBorder(8, 8, 8, 8)); root.add(playerBar(), BorderLayout.NORTH); - JSplitPane workspace = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftWorkspace(), rightWorkspace()); + JSplitPane workspace = new JSplitPane( + JSplitPane.HORIZONTAL_SPLIT, + leftWorkspace(), + rightWorkspace() + ); workspace.setResizeWeight(0.52); workspace.setDividerLocation(760); root.add(workspace, BorderLayout.CENTER); root.add(footer(), BorderLayout.SOUTH); setContentPane(root); - wirePlayerActions(); - wireRuntimeActions(); configureScenarioEditor(); configureStepEditor(); + configureMappingEditor(); + wirePlayerActions(); + wireSessionActions(); syncScenarioView(); updatePlayerView(null); @@ -167,18 +159,9 @@ private JPanel playerBar() { bar.add(project, BorderLayout.WEST); JPanel controls = new JPanel(new FlowLayout(FlowLayout.CENTER, 6, 0)); - controls.add(firstButton); - controls.add(backButton); controls.add(playButton); controls.add(pauseButton); controls.add(playerStopButton); - controls.add(Box.createHorizontalStrut(8)); - controls.add(new JLabel("Speed:")); - JComboBox speed = new JComboBox<>(new String[]{"0.5x", "1.0x", "2.0x"}); - speed.setSelectedItem("1.0x"); - speed.setEnabled(false); - speed.setToolTipText("Runtime playback speed is introduced with the Phase 2 execution loop."); - controls.add(speed); bar.add(controls, BorderLayout.CENTER); JPanel state = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); @@ -206,10 +189,8 @@ private JComponent scenarioPanel() { panel.add(new JScrollPane(scenarioList), BorderLayout.CENTER); JPanel legend = new JPanel(new FlowLayout(FlowLayout.LEFT, 18, 2)); - legend.add(new JLabel("▶ Playhead")); - legend.add(new JLabel("Selected line = highlight")); - legend.add(new JLabel("Executed = dimmed")); - legend.add(new JLabel("Failed = red")); + legend.add(new JLabel("▶ Current execution")); + legend.add(new JLabel("Selection = edit / From Here")); panel.add(legend, BorderLayout.SOUTH); return panel; } @@ -222,14 +203,15 @@ private JComponent stepPanel() { )); JPanel header = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); - JLabel title = new JLabel("Step Editor / Command"); + JLabel title = new JLabel("Step Editor"); title.setFont(title.getFont().deriveFont(Font.BOLD)); header.add(title); - header.add(isolatedStepButton); + header.add(stepOnlyButton); + header.add(fromHereButton); + header.add(new JLabel("Enter = insert after selection Ctrl+Enter = update selected step")); panel.add(header, BorderLayout.NORTH); stepText.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); - stepText.setToolTipText("Enter inserts ahead of the playhead. Ctrl+Enter updates the selected pending step."); panel.add(stepText, BorderLayout.CENTER); return panel; } @@ -242,6 +224,11 @@ private JComponent rightWorkspace() { return tabs; } + /** + * Mapping deliberately has no get/put/resolve workflow. The selected current + * NodeMap is represented as one editable JSON object snapshot and valid edits + * are restored automatically after a short debounce. + */ private JPanel mappingPanel() { JPanel panel = new JPanel(new BorderLayout(6, 6)); panel.setBorder(new EmptyBorder(8, 8, 8, 8)); @@ -249,36 +236,17 @@ private JPanel mappingPanel() { JPanel selector = new JPanel(new BorderLayout(6, 0)); selector.add(new JLabel("NodeMap:"), BorderLayout.WEST); nodeMapSelector.setEnabled(false); - nodeMapSelector.setToolTipText("Populated from the selected step ParsingMap after the Phase 3 bridge contract is added."); selector.add(nodeMapSelector, BorderLayout.CENTER); - JLabel pending = new JLabel("ParsingMap inspection API required"); - pending.setForeground(Color.GRAY); - selector.add(pending, BorderLayout.EAST); panel.add(selector, BorderLayout.NORTH); - JPanel legacy = new JPanel(new BorderLayout(6, 6)); - legacy.setBorder(BorderFactory.createTitledBorder("Existing Mapping operations")); - - JPanel fields = new JPanel(new GridLayout(4, 2, 6, 6)); - fields.add(new JLabel("Mapping reference")); - fields.add(mappingReference); - fields.add(new JLabel("Key")); - fields.add(mappingKey); - fields.add(new JLabel("Value (text)")); - fields.add(mappingValue); - fields.add(new JLabel("Resolve input")); - fields.add(mappingInput); - legacy.add(fields, BorderLayout.NORTH); - - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(mappingGetButton); - actions.add(mappingPutButton); - actions.add(mappingResolveButton); - legacy.add(actions, BorderLayout.CENTER); - legacy.add(new JScrollPane(mappingOutput), BorderLayout.SOUTH); - mappingOutput.setRows(10); - - panel.add(legacy, BorderLayout.CENTER); + mappingEditor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + mappingEditor.setTabSize(2); + mappingEditor.setLineWrap(false); + mappingEditor.setEnabled(false); + panel.add(new JScrollPane(mappingEditor), BorderLayout.CENTER); + + mappingStatus.setBorder(new EmptyBorder(2, 2, 2, 2)); + panel.add(mappingStatus, BorderLayout.SOUTH); return panel; } @@ -286,13 +254,13 @@ private JPanel terminalPanel() { JPanel panel = new JPanel(new BorderLayout(6, 6)); panel.setBorder(new EmptyBorder(8, 8, 8, 8)); - JPanel top = new JPanel(new BorderLayout(6, 0)); - JLabel note = new JLabel("Workbench activity; worker log streaming is introduced in Phase 4."); - note.setForeground(Color.GRAY); - top.add(note, BorderLayout.CENTER); + JPanel top = new JPanel(new BorderLayout()); + JLabel note = new JLabel("Live player and Workbench activity"); + top.add(note, BorderLayout.WEST); JButton clear = new JButton("Clear"); clear.addActionListener(event -> terminalArea.setText("")); top.add(clear, BorderLayout.EAST); + panel.add(top, BorderLayout.NORTH); panel.add(new JScrollPane(terminalArea), BorderLayout.CENTER); return panel; @@ -305,26 +273,36 @@ private JPanel diagnosticsPanel() { message.setText(""" Diagnostic Log Explorer foundation - Phase 5 will bind this tab to Pickleball's retained diagnostic artifacts using the existing - run-catalog -> run-index/clusters -> summary -> events -> visual evidence escalation model. - - No fake run catalog or competing diagnostic storage is created by the Swing UI. + This tab remains intentionally separate from the live scenario player. + It should continue to bind to Pickleball's retained diagnostic artifacts + rather than inventing a second diagnostic store in Workbench. """); message.setCaretPosition(0); panel.add(new JScrollPane(message), BorderLayout.CENTER); return panel; } + private JPanel footer() { + JPanel footer = new JPanel(new BorderLayout()); + activityLabel.setBorder(new EmptyBorder(2, 4, 2, 4)); + footer.add(activityLabel, BorderLayout.CENTER); + return footer; + } + private void configureScenarioEditor() { scenarioList.addListSelectionListener(event -> { if (event.getValueIsAdjusting() || syncingScenarioSelection) return; LiveScenarioPlayer.Line selected = scenarioList.getSelectedValue(); if (selected == null) { player.clearSelection(); + updateFromHereAvailability(); return; } player.select(selected.id()); - if (selected.executable()) stepText.setText(selected.text()); + if (selected.executable()) { + stepText.setText(selected.text()); + } + updateFromHereAvailability(); }); } @@ -342,102 +320,238 @@ public void actionPerformed(java.awt.event.ActionEvent event) { }); } - private void wirePlayerActions() { - firstButton.addActionListener(event -> { - player.movePlayheadToFirstStep(); - updatePlayerView("Playhead moved to first step. Navigation does not undo runtime side effects."); + private void configureMappingEditor() { + nodeMapSelector.addActionListener(event -> { + if (refreshingCatalog) return; + WorkbenchUiController.MappingCatalogEntry selected = + (WorkbenchUiController.MappingCatalogEntry) nodeMapSelector.getSelectedItem(); + if (selected != null) loadMapping(selected); }); - backButton.addActionListener(event -> { - player.movePlayheadToPreviousStep(); - updatePlayerView("Playhead moved back. Navigation does not undo runtime side effects."); - }); - playButton.addActionListener(event -> { - player.play(); - updatePlayerView("Player state updated. Buffered automatic execution is implemented in Phase 2."); + + mappingEditor.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent event) { + mappingChanged(); + } + + @Override + public void removeUpdate(DocumentEvent event) { + mappingChanged(); + } + + @Override + public void changedUpdate(DocumentEvent event) { + mappingChanged(); + } }); + } + + private void wirePlayerActions() { + playButton.addActionListener(event -> runScenarioFromBeginning()); pauseButton.addActionListener(event -> { player.pause(); - updatePlayerView("Player paused."); + updatePlayerView(playbackBusy + ? "Pause requested; the current step will finish first." + : "Scenario playback paused."); }); playerStopButton.addActionListener(event -> { player.stop(); - updatePlayerView("Player stopped. Worker lifecycle remains available from Session."); + pendingFreshRun = false; + pendingFreshRunStepId = null; + pendingIsolatedStep = null; + updatePlayerView(playbackBusy + ? "Scenario playback stopped; the current step will finish but no next step will start." + : "Scenario playback stopped."); }); - isolatedStepButton.addActionListener(event -> executeIsolatedStep()); + stepOnlyButton.addActionListener(event -> executeStepOnly()); + fromHereButton.addActionListener(event -> runScenarioFromSelectedStep()); } - private void wireRuntimeActions() { - syncItem.addActionListener(event -> runStateAction("Synchronizing project", controller::synchronize)); - refreshItem.addActionListener(event -> runStateAction("Refreshing status", controller::refresh)); - startItem.addActionListener(event -> runStateAction("Starting worker", controller::startWorker)); - restartItem.addActionListener(event -> runStateAction("Restarting worker", controller::restartWorker)); - stopItem.addActionListener(event -> runStateAction("Stopping worker", controller::stopWorker)); + private void wireSessionActions() { + syncItem.addActionListener(event -> + runStateAction("Synchronizing project", controller::synchronize)); + refreshItem.addActionListener(event -> + runStateAction("Refreshing status", controller::refresh)); + startItem.addActionListener(event -> + runStateAction("Starting worker", controller::startWorker)); + restartItem.addActionListener(event -> + runStateAction("Restarting worker", controller::restartWorker)); + stopItem.addActionListener(event -> { + player.stop(); + runStateAction("Stopping worker", controller::stopWorker); + }); + } - mappingGetButton.addActionListener(event -> runLiveAction( - "Reading Mapping value", - () -> controller.mappingGet(mappingReference.getText(), mappingKey.getText()), - mappingOutput::setText - )); - mappingPutButton.addActionListener(event -> runLiveAction( - "Writing Mapping value", - () -> controller.mappingPut(mappingReference.getText(), mappingKey.getText(), mappingValue.getText()), - mappingOutput::setText - )); - mappingResolveButton.addActionListener(event -> runLiveAction( - "Resolving Mapping input", - () -> controller.mappingResolve(mappingInput.getText()), - mappingOutput::setText - )); - eventsRefreshButton.addActionListener(event -> runTextAction( - "Refreshing semantic events", - controller::refreshEvents, - this::appendEvents - )); + private void runScenarioFromBeginning() { + requestFreshRun(null); + } - overrideCompileButton.addActionListener(event -> runManagementAction( - "Compiling Step Override", - () -> controller.compileStepOverride(overrideId.getText(), overrideRegex.getText(), overrideSource.getText()), - overrideOutput, - overrideList - )); - overrideRefreshButton.addActionListener(event -> runTextAction( - "Refreshing Step Overrides", controller::stepOverrides, overrideList::setText)); - overrideRemoveButton.addActionListener(event -> runManagementAction( - "Removing Step Override", () -> controller.removeStepOverride(overrideId.getText()), overrideOutput, overrideList)); - overrideClearButton.addActionListener(event -> runManagementAction( - "Clearing Step Overrides", controller::clearStepOverrides, overrideOutput, overrideList)); - - browserPageButton.addActionListener(event -> runLiveAction( - "Reading browser page evidence", controller::browserPage, browserOutput::setText)); - browserScreenshotButton.addActionListener(event -> runBackground( - "Capturing browser screenshot", controller::browserScreenshot, this::applyScreenshot)); - serviceCallButton.addActionListener(event -> runLiveAction( - "Executing service call", () -> controller.serviceCall(serviceSelector.getText()), serviceOutput::setText)); - - breakpointAddButton.addActionListener(event -> runManagementAction( - "Adding breakpoint", - () -> controller.addBreakpoint( - breakpointHook.getText(), breakpointSignature.getText(), breakpointStep.getText(), - breakpointPhrase.getText(), breakpointOneShot.isSelected(), breakpointLease.getText()), - breakpointOutput, - breakpointList - )); - breakpointRefreshButton.addActionListener(event -> runTextAction( - "Refreshing breakpoints", controller::breakpoints, breakpointList::setText)); - breakpointRemoveButton.addActionListener(event -> runManagementAction( - "Removing breakpoint", () -> controller.removeBreakpoint(breakpointId.getText()), breakpointOutput, breakpointList)); - breakpointClearButton.addActionListener(event -> runManagementAction( - "Clearing breakpoints", controller::clearBreakpoints, breakpointOutput, breakpointList)); + private void runScenarioFromSelectedStep() { + LiveScenarioPlayer.Line selected = player.selectedLine().orElse(null); + if (selected == null || !selected.executable()) { + showFailure("Could not run from selected step", + new IllegalStateException("Select an executable scenario step first.")); + return; + } + requestFreshRun(selected.id()); + } + + private void requestFreshRun(Long startStepId) { + pendingIsolatedStep = null; + if (playbackBusy || playbackPreparing) { + pendingFreshRun = true; + pendingFreshRunStepId = startStepId; + player.stop(); + updatePlayerView(startStepId == null + ? "Run from start queued after the current operation." + : "Run from selected step queued after the current operation."); + return; + } + startFreshRunNow(startStepId); + } + + private void startFreshRunNow(Long startStepId) { + try { + if (startStepId == null) { + player.startFromBeginning(); + updatePlayerView("Starting a fresh scenario run from the first step..."); + } else { + player.select(startStepId); + player.startFromSelectedStep(); + selectLine(startStepId); + updatePlayerView("Starting a fresh scenario run from the selected step..."); + } + } catch (RuntimeException failure) { + showFailure("Could not start scenario playback", failure); + return; + } + prepareFreshLiveSession(this::schedulePlaybackStep); + } + + private void prepareFreshLiveSession(Runnable readyAction) { + if (playbackPreparing) return; + playbackPreparing = true; + activityLabel.setText("Preparing fresh scenario context..."); + runTask( + controller::prepareFreshLiveSession, + state -> { + playbackPreparing = false; + applyState(state); + if (runPendingFreshRun()) return; + if (readyAction != null) readyAction.run(); + runPendingIsolatedStep(); + }, + failure -> { + playbackPreparing = false; + player.pause(); + updatePlayerView("Fresh scenario preparation failed."); + showFailure("Could not prepare fresh live session", failure); + } + ); + } + + private void prepareLiveSession(Runnable readyAction) { + if (playbackPreparing) return; + playbackPreparing = true; + activityLabel.setText("Preparing live session..."); + runTask( + controller::prepareLiveSession, + state -> { + playbackPreparing = false; + applyState(state); + if (runPendingFreshRun()) return; + if (readyAction != null) { + readyAction.run(); + } else { + refreshMappingCatalog(); + } + runPendingIsolatedStep(); + }, + failure -> { + playbackPreparing = false; + player.pause(); + updatePlayerView("Live session preparation failed."); + showFailure("Could not prepare live session", failure); + } + ); + } + + /** Executes one scenario line per background task so pause/stop remain responsive. */ + private void schedulePlaybackStep() { + if (playbackPreparing || playbackBusy || mappingSaveBusy || refreshingCatalog) return; + if (player.state() != LiveScenarioPlayer.State.RUNNING) { + updatePlayerView(null); + return; + } + + LiveScenarioPlayer.Line step = player.nextStep().orElse(null); + if (step == null) { + updatePlayerView("Scenario is waiting for another step."); + return; + } + + playbackBusy = true; + executingStepId = step.id(); + updatePlayerView("Executing: " + step.text()); + runTask( + () -> controller.executePlayerStep(step.text()), + result -> { + playbackBusy = false; + executingStepId = null; + appendTerminal(step.text(), result.output(), result.events()); + + if (result.successful()) { + player.markCurrentStepExecuted(step.id()); + } else { + player.markCurrentStepFailed(step.id()); + player.select(step.id()); + selectLine(step.id()); + } + syncScenarioView(); + updatePlayerView( + result.successful() + ? null + : "Step failed. Scenario playback paused on the failed step." + ); + if (runPendingFreshRun()) return; + if (player.state() != LiveScenarioPlayer.State.RUNNING) { + refreshMappingCatalog(); + } + if (!runPendingIsolatedStep()) { + schedulePlaybackStep(); + } + }, + failure -> { + playbackBusy = false; + executingStepId = null; + player.markCurrentStepFailed(step.id()); + player.select(step.id()); + syncScenarioView(); + selectLine(step.id()); + updatePlayerView("Step execution failed. Scenario playback paused."); + showFailure("Could not execute live step", failure); + if (!runPendingFreshRun()) runPendingIsolatedStep(); + } + ); } private void insertStep() { try { LiveScenarioPlayer.Line inserted = player.insertStep(stepText.getText()); + stepText.setText(""); + player.select(inserted.id()); syncScenarioView(); - appendTerminal("Inserted live buffer step #" + inserted.id() + ": " + inserted.text()); - updatePlayerView(null); + selectLine(inserted.id()); + updatePlayerView("Inserted step after the selected line."); + if (player.state() == LiveScenarioPlayer.State.RUNNING) { + if (lastState == null || !lastState.liveReady()) { + prepareLiveSession(this::schedulePlaybackStep); + } else { + schedulePlaybackStep(); + } + } } catch (RuntimeException failure) { - showFailure("Insert step", failure); + showFailure("Could not insert step", failure); } } @@ -445,245 +559,314 @@ private void updateSelectedStep() { try { LiveScenarioPlayer.Line updated = player.updateSelectedStep(stepText.getText()); syncScenarioView(); - appendTerminal("Updated pending live buffer step #" + updated.id() + ": " + updated.text()); - updatePlayerView(null); + selectLine(updated.id()); + updatePlayerView("Updated selected step."); } catch (RuntimeException failure) { - showFailure("Update selected step", failure); + showFailure("Could not update selected step", failure); } } - private void executeIsolatedStep() { + private void executeStepOnly() { String text = stepText.getText(); if (text == null || text.isBlank()) { - showFailure("Execute isolated step", new IllegalArgumentException("Gherkin step must not be blank.")); + showFailure("Could not execute step", + new IllegalArgumentException("Step Editor text must not be blank.")); return; } + player.pauseForIsolatedExecution(); - updatePlayerView("Main player paused for isolated execution."); - runLiveAction( - "Executing isolated Gherkin", - () -> controller.executeStep(text, ""), - output -> appendTerminal("Isolated step\n" + output) - ); - } + updatePlayerView("Scenario playback paused for Step Only execution."); - private void syncScenarioView() { - Long selectedId = player.selectedId().isPresent() ? player.selectedId().getAsLong() : null; - syncingScenarioSelection = true; - try { - scenarioModel.clear(); - int selectedIndex = -1; - int index = 0; - for (LiveScenarioPlayer.Line line : player.lines()) { - scenarioModel.addElement(line); - if (selectedId != null && line.id() == selectedId) selectedIndex = index; - index++; - } - if (selectedIndex >= 0) scenarioList.setSelectedIndex(selectedIndex); - } finally { - syncingScenarioSelection = false; + if (playbackBusy || playbackPreparing) { + pendingIsolatedStep = text; + activityLabel.setText("Step Only execution queued after the current operation."); + return; } - scenarioList.repaint(); + executeStepOnlyNow(text); } - private void updatePlayerView(String activity) { - playerStatusLabel.setText(switch (player.state()) { - case STOPPED -> "Stopped"; - case PAUSED -> "Paused"; - case RUNNING -> "Running"; - case WAITING_FOR_STEP -> "Waiting for next step..."; - }); - if (activity != null && !activity.isBlank()) { - activityLabel.setText(activity); - appendTerminal(activity); + private void executeStepOnlyNow(String text) { + Runnable execute = () -> { + playbackBusy = true; + runTask( + () -> controller.executePlayerStep(text), + result -> { + playbackBusy = false; + appendTerminal("[step only] " + text, result.output(), result.events()); + updatePlayerView("Step Only finished; automatic scenario playback remains paused."); + if (!runPendingFreshRun()) refreshMappingCatalog(); + }, + failure -> { + playbackBusy = false; + showFailure("Could not execute step", failure); + runPendingFreshRun(); + } + ); + }; + + if (lastState != null && lastState.liveReady()) { + execute.run(); + } else { + prepareLiveSession(execute); } - scenarioList.repaint(); - restoreControls(); } - private JPanel footer() { - JPanel panel = new JPanel(new BorderLayout(8, 0)); - panel.add(activityLabel, BorderLayout.WEST); - JLabel hint = new JLabel("Session lifecycle: Session menu • Existing investigation tools: Tools > Advanced Controls"); - hint.setForeground(Color.GRAY); - panel.add(hint, BorderLayout.EAST); - return panel; + private boolean runPendingFreshRun() { + if (!pendingFreshRun || playbackBusy || playbackPreparing) return false; + Long startStepId = pendingFreshRunStepId; + pendingFreshRun = false; + pendingFreshRunStepId = null; + startFreshRunNow(startStepId); + return true; } - private void showAdvancedControls() { - if (advancedDialog == null) { - advancedDialog = new JDialog(this, "Workbench Advanced Controls", false); - advancedDialog.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE); - advancedDialog.setSize(980, 720); - advancedDialog.setLocationRelativeTo(this); - - JTabbedPane tabs = new JTabbedPane(); - tabs.addTab("Status", new JScrollPane(statusArea)); - tabs.addTab("Recent Events", eventsPanel()); - tabs.addTab("Step Overrides", stepOverridePanel()); - tabs.addTab("Evidence", evidencePanel()); - tabs.addTab("Breakpoints", breakpointPanel()); - advancedDialog.setContentPane(tabs); - } - advancedDialog.setVisible(true); - advancedDialog.toFront(); + private boolean runPendingIsolatedStep() { + if (pendingIsolatedStep == null || playbackBusy || playbackPreparing || pendingFreshRun) return false; + String text = pendingIsolatedStep; + pendingIsolatedStep = null; + executeStepOnlyNow(text); + return true; } - private JPanel eventsPanel() { - JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(new EmptyBorder(8, 8, 8, 8)); - panel.add(eventsRefreshButton, BorderLayout.NORTH); - panel.add(new JScrollPane(eventsArea), BorderLayout.CENTER); - return panel; + private void mappingChanged() { + if (loadingMapping || loadedMapping == null || !loadedMapping.restorable()) return; + if (player.state() == LiveScenarioPlayer.State.RUNNING + || player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP) { + player.pause(); + updatePlayerView("Player paused for live Mapping edit."); + } + mappingEditGeneration++; + mappingStatus.setText("Editing " + loadedMapping.mapType() + "..."); + mappingSaveTimer.restart(); } - private JPanel stepOverridePanel() { - JPanel fields = new JPanel(new GridLayout(2, 2, 6, 6)); - fields.add(new JLabel("ID")); - fields.add(overrideId); - fields.add(new JLabel("Regex")); - fields.add(overrideRegex); - - JPanel source = new JPanel(new BorderLayout(6, 6)); - source.setBorder(new EmptyBorder(8, 8, 8, 8)); - source.add(fields, BorderLayout.NORTH); - source.add(new JScrollPane(overrideSource), BorderLayout.CENTER); - - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(overrideCompileButton); - actions.add(overrideRefreshButton); - actions.add(overrideRemoveButton); - actions.add(overrideClearButton); - source.add(actions, BorderLayout.SOUTH); - - JTabbedPane outputs = new JTabbedPane(); - outputs.addTab("Result", new JScrollPane(overrideOutput)); - outputs.addTab("Installed", new JScrollPane(overrideList)); - - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, source, outputs); - split.setResizeWeight(0.62); - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); - return panel; - } + private void refreshMappingCatalog() { + if (refreshingCatalog || lastState == null || !lastState.liveReady()) { + if (lastState == null || !lastState.liveReady()) { + nodeMapSelector.setEnabled(false); + mappingEditor.setEnabled(false); + } + return; + } - private JPanel evidencePanel() { - JTabbedPane tabs = new JTabbedPane(); - tabs.addTab("Browser", browserEvidencePanel()); - tabs.addTab("Service Call", serviceEvidencePanel()); - JPanel panel = new JPanel(new BorderLayout()); - panel.add(tabs, BorderLayout.CENTER); - return panel; + refreshingCatalog = true; + WorkbenchUiController.MappingCatalogEntry previous = + (WorkbenchUiController.MappingCatalogEntry) nodeMapSelector.getSelectedItem(); + String previousReference = previous == null ? null : previous.reference(); + + runTask( + controller::mappingCatalog, + entries -> { + refreshingCatalog = false; + nodeMapSelector.removeAllItems(); + WorkbenchUiController.MappingCatalogEntry selected = null; + for (WorkbenchUiController.MappingCatalogEntry entry : entries) { + nodeMapSelector.addItem(entry); + if (Objects.equals(previousReference, entry.reference())) selected = entry; + } + nodeMapSelector.setEnabled(!entries.isEmpty()); + if (selected == null && !entries.isEmpty()) selected = entries.getFirst(); + if (selected != null) { + nodeMapSelector.setSelectedItem(selected); + loadMapping(selected); + } else { + loadedMapping = null; + setMappingEditor("", false); + mappingStatus.setText("No NodeMaps are available in the current ParsingMap."); + } + schedulePlaybackStep(); + }, + failure -> { + refreshingCatalog = false; + nodeMapSelector.setEnabled(false); + mappingEditor.setEnabled(false); + mappingStatus.setText("Could not read current ParsingMap: " + failure.getMessage()); + schedulePlaybackStep(); + } + ); } - private JPanel browserEvidencePanel() { - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(browserPageButton); - actions.add(browserScreenshotButton); + private void loadMapping(WorkbenchUiController.MappingCatalogEntry entry) { + if (entry == null || lastState == null || !lastState.liveReady()) return; + mappingStatus.setText("Loading " + entry.label() + "..."); + runTask( + () -> controller.mappingSnapshot(entry.reference()), + snapshot -> { + loadedMapping = snapshot; + try { + String formatted = json.writerWithDefaultPrettyPrinter() + .writeValueAsString(snapshot.values()); + setMappingEditor(formatted, snapshot.restorable()); + mappingStatus.setText( + snapshot.restorable() + ? "Live JSON snapshot. Valid edits are applied automatically." + : "Inspection only: this NodeMap implementation is not safely restorable." + ); + } catch (Exception failure) { + showFailure("Could not render NodeMap JSON", failure); + } + }, + failure -> { + loadedMapping = null; + setMappingEditor("", false); + mappingStatus.setText("Could not load NodeMap: " + failure.getMessage()); + } + ); + } - screenshotLabel.setVerticalAlignment(SwingConstants.TOP); - JScrollPane screenshotScroll = new JScrollPane(screenshotLabel); - screenshotScroll.getVerticalScrollBar().setUnitIncrement(16); - screenshotScroll.getHorizontalScrollBar().setUnitIncrement(16); + private void saveEditedMapping() { + if (loadedMapping == null || !loadedMapping.restorable() || mappingSaveBusy) return; + if (playbackBusy || playbackPreparing) { + mappingStatus.setText("Waiting for the current player operation before applying Mapping edit..."); + mappingSaveTimer.restart(); + return; + } - browserEvidenceTabs.addTab("Page Evidence", new JScrollPane(browserOutput)); - browserEvidenceTabs.addTab("Screenshot", screenshotScroll); + Map values; + try { + values = json.readValue( + mappingEditor.getText(), + new TypeReference>() { } + ); + } catch (Exception invalidJson) { + mappingStatus.setText("Invalid JSON — edit has not been applied."); + return; + } - JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(new EmptyBorder(8, 8, 8, 8)); - panel.add(actions, BorderLayout.NORTH); - panel.add(browserEvidenceTabs, BorderLayout.CENTER); - return panel; + long generation = mappingEditGeneration; + ControlBridgeMappingSnapshot snapshot = loadedMapping; + mappingSaveBusy = true; + mappingStatus.setText("Applying live Mapping edit..."); + runTask( + () -> controller.restoreMapping(snapshot, values), + output -> { + mappingSaveBusy = false; + appendTerminal("[Mapping] " + snapshot.mapType(), output, ""); + if (generation == mappingEditGeneration) { + mappingStatus.setText("Saved to live " + snapshot.mapType() + "."); + } else { + mappingSaveTimer.restart(); + } + schedulePlaybackStep(); + }, + failure -> { + mappingSaveBusy = false; + mappingStatus.setText("Mapping edit was not applied: " + failure.getMessage()); + } + ); } - private JPanel serviceEvidencePanel() { - JPanel controls = new JPanel(new BorderLayout(6, 6)); - controls.add(new JLabel("Selector"), BorderLayout.WEST); - controls.add(serviceSelector, BorderLayout.CENTER); - controls.add(serviceCallButton, BorderLayout.EAST); + private void setMappingEditor(String text, boolean editable) { + loadingMapping = true; + try { + mappingEditor.setText(text); + mappingEditor.setCaretPosition(0); + mappingEditor.setEnabled(true); + mappingEditor.setEditable(editable); + } finally { + loadingMapping = false; + } + } - JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(new EmptyBorder(8, 8, 8, 8)); - panel.add(controls, BorderLayout.NORTH); - panel.add(new JScrollPane(serviceOutput), BorderLayout.CENTER); - return panel; + private void syncScenarioView() { + Long selected = player.selectedId().isPresent() + ? player.selectedId().getAsLong() + : null; + syncingScenarioSelection = true; + try { + scenarioModel.clear(); + for (LiveScenarioPlayer.Line line : player.lines()) { + scenarioModel.addElement(line); + } + if (selected != null) selectLine(selected); + } finally { + syncingScenarioSelection = false; + } + scenarioList.repaint(); + updateFromHereAvailability(); } - private JPanel breakpointPanel() { - JPanel fields = new JPanel(new GridLayout(7, 2, 6, 6)); - fields.add(new JLabel("Breakpoint ID (for remove)")); - fields.add(breakpointId); - fields.add(new JLabel("Hook")); - fields.add(breakpointHook); - fields.add(new JLabel("Signature contains")); - fields.add(breakpointSignature); - fields.add(new JLabel("Step contains")); - fields.add(breakpointStep); - fields.add(new JLabel("Phrase contains")); - fields.add(breakpointPhrase); - fields.add(new JLabel("Lease seconds")); - fields.add(breakpointLease); - fields.add(new JLabel("Behavior")); - fields.add(breakpointOneShot); - - JPanel controls = new JPanel(new BorderLayout()); - controls.setBorder(new EmptyBorder(8, 8, 8, 8)); - controls.add(fields, BorderLayout.CENTER); - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(breakpointAddButton); - actions.add(breakpointRefreshButton); - actions.add(breakpointRemoveButton); - actions.add(breakpointClearButton); - controls.add(actions, BorderLayout.SOUTH); - - JTabbedPane outputs = new JTabbedPane(); - outputs.addTab("Result", new JScrollPane(breakpointOutput)); - outputs.addTab("Installed", new JScrollPane(breakpointList)); - - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, controls, outputs); - split.setResizeWeight(0.5); - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); - return panel; + private void updateFromHereAvailability() { + fromHereButton.setEnabled( + player.selectedLine().map(LiveScenarioPlayer.Line::executable).orElse(false) + ); } - private void runStateAction(String label, Supplier action) { - runBackground(label, action, this::applyState); + private void selectLine(long id) { + for (int i = 0; i < scenarioModel.size(); i++) { + if (scenarioModel.get(i).id() == id) { + scenarioList.setSelectedIndex(i); + scenarioList.ensureIndexIsVisible(i); + return; + } + } } - private void runLiveAction( - String label, - Supplier action, - Consumer output - ) { - runBackground(label, action, result -> { - output.accept(result.output()); - appendEvents(result.events()); + private void updatePlayerView(String activity) { + playerStatusLabel.setText(switch (player.state()) { + case STOPPED -> "Stopped"; + case PAUSED -> "Paused"; + case RUNNING -> playbackBusy ? "Running" : "Playing"; + case WAITING_FOR_STEP -> "Waiting for step"; }); + if (activity != null && !activity.isBlank()) activityLabel.setText(activity); + syncScenarioView(); } - private void runManagementAction( + private void appendTerminal(String heading, String output, String events) { + if (!terminalArea.getText().isEmpty()) terminalArea.append("\n\n"); + terminalArea.append(heading + "\n"); + if (output != null && !output.isBlank()) terminalArea.append(output + "\n"); + if (events != null && !events.isBlank()) { + terminalArea.append("Events\n" + events + "\n"); + } + terminalArea.setCaretPosition(terminalArea.getDocument().getLength()); + } + + private void runStateAction( String label, - Supplier action, - JTextArea output, - JTextArea listing + Supplier action ) { - runBackground(label, action, result -> { - output.setText(result.output()); - listing.setText(result.listing()); - }); + activityLabel.setText(label + "..."); + runTask( + action, + state -> { + applyState(state); + activityLabel.setText(label + " complete."); + if (state.liveReady()) refreshMappingCatalog(); + }, + failure -> showFailure(label + " failed", failure) + ); } - private void runTextAction(String label, Supplier action, Consumer output) { - runBackground(label, action, output); + private void applyState(WorkbenchUiController.State state) { + lastState = state; + projectLabel.setText("Project: " + state.projectRoot().getFileName()); + readinessLabel.setText( + state.liveReady() + ? "Live worker ready" + : state.synchronizedProject() + ? "Synchronized" + : "Not synchronized" + ); + syncItem.setEnabled(!state.workerRunning()); + startItem.setEnabled(state.synchronizedProject() && !state.workerRunning()); + restartItem.setEnabled(state.workerRunning()); + stopItem.setEnabled(state.workerRunning()); + + if (!state.liveReady()) { + nodeMapSelector.setEnabled(false); + loadedMapping = null; + setMappingEditor("", false); + mappingStatus.setText("Start the live worker to inspect Mapping."); + } } - private void runBackground(String label, Supplier action, Consumer success) { - if (closing) return; - setControlsEnabled(false); - activityLabel.setText(label + "..."); - appendTerminal(label + "..."); - + private void runTask( + Supplier action, + Consumer success, + Consumer failure + ) { new SwingWorker() { @Override protected T doInBackground() { @@ -694,206 +877,286 @@ protected T doInBackground() { protected void done() { try { success.accept(get()); - activityLabel.setText(label + " complete."); - appendTerminal(label + " complete."); - } catch (InterruptedException failure) { + } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); - showFailure(label, failure); - } catch (ExecutionException failure) { - showFailure(label, failure.getCause()); - } finally { - restoreControls(); + failure.accept(interrupted); + } catch (ExecutionException execution) { + failure.accept(execution.getCause() == null ? execution : execution.getCause()); + } catch (RuntimeException runtime) { + failure.accept(runtime); } } }.execute(); } - private void applyState(WorkbenchUiController.State state) { - lastState = state; - projectLabel.setText("Project: " + displayProject(state)); - readinessLabel.setText(readiness(state)); - statusArea.setText(state.render()); - statusArea.setCaretPosition(0); - if (!state.workerRunning()) { - screenshotLabel.setIcon(null); - screenshotLabel.setText("No screenshot captured."); - } + private void showFailure(String label, Throwable failure) { + String message = failure == null + ? label + : label + ": " + Objects.toString(failure.getMessage(), failure.getClass().getSimpleName()); + activityLabel.setText(message); + JOptionPane.showMessageDialog( + this, + message, + "Pickleball Workbench", + JOptionPane.ERROR_MESSAGE + ); } - private static String displayProject(WorkbenchUiController.State state) { - return state.projectRoot().getFileName() == null - ? state.projectRoot().toString() - : state.projectRoot().getFileName().toString(); - } + /** + * Existing non-Mapping investigation features remain available without + * competing with the primary player workspace. + */ + private void showAdvancedControls() { + JDialog dialog = new JDialog(this, "Advanced Controls", false); + dialog.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + dialog.setSize(900, 700); + dialog.setLocationRelativeTo(this); - private static String readiness(WorkbenchUiController.State state) { - if (!state.synchronizedProject()) return "Synchronization required"; - if (!state.workerRunning()) return "Synchronized"; - return state.liveReady() ? "Ready" : "Worker running"; + JTabbedPane tabs = new JTabbedPane(); + tabs.addTab("Status / Events", advancedStatusPanel()); + tabs.addTab("Step Overrides", advancedOverridesPanel()); + tabs.addTab("Browser / Service", advancedBrowserServicePanel()); + tabs.addTab("Breakpoints", advancedBreakpointsPanel()); + dialog.setContentPane(tabs); + dialog.setVisible(true); } - private void applyScreenshot(WorkbenchUiController.ScreenshotResult result) { - browserOutput.setText(result.output()); - appendEvents(result.events()); - if (result.png() == null || result.png().length == 0) { - screenshotLabel.setIcon(null); - screenshotLabel.setText("No screenshot returned."); - return; - } - screenshotLabel.setText(null); - screenshotLabel.setIcon(new ImageIcon(result.png())); - browserEvidenceTabs.setSelectedIndex(1); + private JComponent advancedStatusPanel() { + JPanel panel = new JPanel(new BorderLayout(6, 6)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + JTextArea output = outputArea(); + JButton refresh = new JButton("Refresh status and events"); + refresh.addActionListener(event -> runTask( + () -> { + WorkbenchUiController.State state = controller.refresh(); + String events = state.liveReady() ? controller.refreshEvents() : ""; + return state.render() + (events.isBlank() ? "" : "\nEvents\n" + events); + }, + output::setText, + failure -> showFailure("Advanced status refresh failed", failure) + )); + panel.add(refresh, BorderLayout.NORTH); + panel.add(new JScrollPane(output), BorderLayout.CENTER); + return panel; } - private void appendEvents(String text) { - if (text == null || text.isBlank()) return; - if (!eventsArea.getText().isBlank()) eventsArea.append("\n\n"); - eventsArea.append(text); - eventsArea.setCaretPosition(eventsArea.getDocument().getLength()); - } + private JComponent advancedOverridesPanel() { + JPanel panel = new JPanel(new BorderLayout(6, 6)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + JTextField id = new JTextField("workbench-ui-generated"); + JTextField regex = new JTextField("^WORKBENCH UI OVERRIDE ([A-Za-z]+)$"); + JTextArea source = new JTextArea(defaultOverrideSource(), 14, 60); + JTextArea output = outputArea(); - private void appendTerminal(String text) { - if (text == null || text.isBlank()) return; - if (!terminalArea.getText().isBlank()) terminalArea.append("\n"); - terminalArea.append(text.stripTrailing() + "\n"); - terminalArea.setCaretPosition(terminalArea.getDocument().getLength()); + JPanel fields = new JPanel(new GridLayout(2, 2, 6, 6)); + fields.add(new JLabel("ID")); + fields.add(id); + fields.add(new JLabel("Regex")); + fields.add(regex); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton compile = new JButton("Compile / Replace"); + JButton list = new JButton("Refresh List"); + JButton remove = new JButton("Remove ID"); + JButton clear = new JButton("Clear All"); + buttons.add(compile); + buttons.add(list); + buttons.add(remove); + buttons.add(clear); + + compile.addActionListener(event -> runTask( + () -> controller.compileStepOverride(id.getText(), regex.getText(), source.getText()), + result -> output.setText(result.output() + "\n\n" + result.listing()), + failure -> showFailure("Step Override compile failed", failure) + )); + list.addActionListener(event -> runTask( + controller::stepOverrides, + output::setText, + failure -> showFailure("Step Override list failed", failure) + )); + remove.addActionListener(event -> runTask( + () -> controller.removeStepOverride(id.getText()), + result -> output.setText(result.output() + "\n\n" + result.listing()), + failure -> showFailure("Step Override remove failed", failure) + )); + clear.addActionListener(event -> runTask( + controller::clearStepOverrides, + result -> output.setText(result.output() + "\n\n" + result.listing()), + failure -> showFailure("Step Override clear failed", failure) + )); + + JPanel north = new JPanel(new BorderLayout(6, 6)); + north.add(fields, BorderLayout.NORTH); + north.add(buttons, BorderLayout.SOUTH); + panel.add(north, BorderLayout.NORTH); + + JSplitPane split = new JSplitPane( + JSplitPane.VERTICAL_SPLIT, + new JScrollPane(source), + new JScrollPane(output) + ); + split.setResizeWeight(0.55); + panel.add(split, BorderLayout.CENTER); + return panel; } - private void showFailure(String label, Throwable failure) { - String message = failure == null ? null : failure.getMessage(); - String detail = (message == null || message.isBlank()) ? String.valueOf(failure) : message; - activityLabel.setText(label + " failed: " + detail); - appendTerminal(label + " failed: " + detail); + private JComponent advancedBrowserServicePanel() { + JPanel panel = new JPanel(new BorderLayout(6, 6)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + JTextArea output = outputArea(); + JTextField selector = new JTextField("%health-full-url"); + JButton page = new JButton("Read Page"); + JButton screenshot = new JButton("Capture Screenshot"); + JButton service = new JButton("Execute Service Call"); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT)); + buttons.add(page); + buttons.add(screenshot); + buttons.add(new JLabel("Service:")); + selector.setPreferredSize(new Dimension(220, selector.getPreferredSize().height)); + buttons.add(selector); + buttons.add(service); + + page.addActionListener(event -> runTask( + controller::browserPage, + result -> output.setText(result.output()), + failure -> showFailure("Browser page read failed", failure) + )); + screenshot.addActionListener(event -> runTask( + controller::browserScreenshot, + result -> output.setText(result.output()), + failure -> showFailure("Browser screenshot failed", failure) + )); + service.addActionListener(event -> runTask( + () -> controller.serviceCall(selector.getText()), + result -> output.setText(result.output()), + failure -> showFailure("Service call failed", failure) + )); + + panel.add(buttons, BorderLayout.NORTH); + panel.add(new JScrollPane(output), BorderLayout.CENTER); + return panel; } - private void restoreControls() { - if (closing) return; - boolean running = lastState != null && lastState.workerRunning(); - boolean liveReady = lastState != null && lastState.liveReady(); - - syncItem.setEnabled(!running); - refreshItem.setEnabled(true); - startItem.setEnabled(lastState != null && lastState.synchronizedProject() && !running); - restartItem.setEnabled(running); - stopItem.setEnabled(running); - - boolean hasSteps = player.lines().stream().anyMatch(LiveScenarioPlayer.Line::executable); - firstButton.setEnabled(hasSteps); - backButton.setEnabled(hasSteps); - playButton.setEnabled(true); - pauseButton.setEnabled(player.state() == LiveScenarioPlayer.State.RUNNING - || player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP); - playerStopButton.setEnabled(player.state() != LiveScenarioPlayer.State.STOPPED); - isolatedStepButton.setEnabled(liveReady); - stepText.setEnabled(true); - scenarioList.setEnabled(true); - - mappingGetButton.setEnabled(liveReady); - mappingPutButton.setEnabled(liveReady); - mappingResolveButton.setEnabled(liveReady); - eventsRefreshButton.setEnabled(liveReady); - overrideCompileButton.setEnabled(liveReady); - overrideRefreshButton.setEnabled(liveReady); - overrideRemoveButton.setEnabled(liveReady); - overrideClearButton.setEnabled(liveReady); - browserPageButton.setEnabled(liveReady); - browserScreenshotButton.setEnabled(liveReady); - serviceCallButton.setEnabled(liveReady); - breakpointAddButton.setEnabled(liveReady); - breakpointRefreshButton.setEnabled(liveReady); - breakpointRemoveButton.setEnabled(liveReady); - breakpointClearButton.setEnabled(liveReady); - } - - private void setControlsEnabled(boolean enabled) { - syncItem.setEnabled(enabled); - refreshItem.setEnabled(enabled); - startItem.setEnabled(enabled); - restartItem.setEnabled(enabled); - stopItem.setEnabled(enabled); - - firstButton.setEnabled(enabled); - backButton.setEnabled(enabled); - playButton.setEnabled(enabled); - pauseButton.setEnabled(enabled); - playerStopButton.setEnabled(enabled); - isolatedStepButton.setEnabled(enabled); - stepText.setEnabled(enabled); - scenarioList.setEnabled(enabled); - - mappingGetButton.setEnabled(enabled); - mappingPutButton.setEnabled(enabled); - mappingResolveButton.setEnabled(enabled); - eventsRefreshButton.setEnabled(enabled); - overrideCompileButton.setEnabled(enabled); - overrideRefreshButton.setEnabled(enabled); - overrideRemoveButton.setEnabled(enabled); - overrideClearButton.setEnabled(enabled); - browserPageButton.setEnabled(enabled); - browserScreenshotButton.setEnabled(enabled); - serviceCallButton.setEnabled(enabled); - breakpointAddButton.setEnabled(enabled); - breakpointRefreshButton.setEnabled(enabled); - breakpointRemoveButton.setEnabled(enabled); - breakpointClearButton.setEnabled(enabled); + private JComponent advancedBreakpointsPanel() { + JPanel panel = new JPanel(new BorderLayout(6, 6)); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + + JTextField id = new JTextField(); + JTextField hook = new JTextField("BEFORE_STEP"); + JTextField signature = new JTextField(); + JTextField step = new JTextField("CONTROL API TEST STEP"); + JTextField phrase = new JTextField(); + JTextField lease = new JTextField("120"); + JCheckBox oneShot = new JCheckBox("One shot", true); + JTextArea output = outputArea(); + + JPanel fields = new JPanel(new GridLayout(6, 2, 6, 6)); + fields.add(new JLabel("Breakpoint ID")); + fields.add(id); + fields.add(new JLabel("Hook")); + fields.add(hook); + fields.add(new JLabel("Signature contains")); + fields.add(signature); + fields.add(new JLabel("Step contains")); + fields.add(step); + fields.add(new JLabel("Phrase contains")); + fields.add(phrase); + fields.add(new JLabel("Lease seconds")); + fields.add(lease); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton add = new JButton("Add"); + JButton list = new JButton("Refresh List"); + JButton remove = new JButton("Remove ID"); + JButton clear = new JButton("Clear All"); + buttons.add(oneShot); + buttons.add(add); + buttons.add(list); + buttons.add(remove); + buttons.add(clear); + + add.addActionListener(event -> runTask( + () -> controller.addBreakpoint( + hook.getText(), + signature.getText(), + step.getText(), + phrase.getText(), + oneShot.isSelected(), + lease.getText() + ), + result -> output.setText(result.output() + "\n\n" + result.listing()), + failure -> showFailure("Breakpoint add failed", failure) + )); + list.addActionListener(event -> runTask( + controller::breakpoints, + output::setText, + failure -> showFailure("Breakpoint list failed", failure) + )); + remove.addActionListener(event -> runTask( + () -> controller.removeBreakpoint(id.getText()), + result -> output.setText(result.output() + "\n\n" + result.listing()), + failure -> showFailure("Breakpoint remove failed", failure) + )); + clear.addActionListener(event -> runTask( + controller::clearBreakpoints, + result -> output.setText(result.output() + "\n\n" + result.listing()), + failure -> showFailure("Breakpoint clear failed", failure) + )); + + JPanel north = new JPanel(new BorderLayout(6, 6)); + north.add(fields, BorderLayout.CENTER); + north.add(buttons, BorderLayout.SOUTH); + panel.add(north, BorderLayout.NORTH); + panel.add(new JScrollPane(output), BorderLayout.CENTER); + return panel; } private void closeWorkbench() { if (closing) return; closing = true; - setControlsEnabled(false); - activityLabel.setText("Stopping Workbench resources..."); - - new SwingWorker() { - @Override - protected Void doInBackground() { - controller.close(); - return null; - } - - @Override - protected void done() { - try { - get(); - } catch (InterruptedException failure) { - Thread.currentThread().interrupt(); - } catch (ExecutionException failure) { - Throwable cause = failure.getCause(); - System.err.println("Workbench UI close failed: " - + (cause == null ? failure.getMessage() : cause.getMessage())); - } finally { + player.stop(); + mappingSaveTimer.stop(); + activityLabel.setText("Closing Workbench..."); + runTask( + () -> { + controller.close(); + return Boolean.TRUE; + }, + ignored -> { + dispose(); + }, + failure -> { dispose(); } - } - }.execute(); + ); } - private static JButton playerButton(String glyph, String tooltip) { - JButton button = new JButton(glyph); - button.setToolTipText(tooltip); - button.setPreferredSize(new Dimension(46, 32)); - return button; + private static JTextArea outputArea() { + JTextArea area = new JTextArea(); + area.setEditable(false); + area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + area.setLineWrap(false); + return area; } - private static JButton smallPlayerButton(String glyph, String tooltip) { - JButton button = new JButton(glyph); + private static JButton playerButton(String text, String tooltip) { + JButton button = new JButton(text); button.setToolTipText(tooltip); - button.setMargin(new Insets(1, 7, 1, 7)); button.setFocusable(false); return button; } - private static JTextArea outputArea() { - JTextArea area = new JTextArea(); - area.setEditable(false); - area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); - area.setMargin(new Insets(8, 8, 8, 8)); - return area; + private static JButton smallPlayerButton(String text, String tooltip) { + JButton button = playerButton(text, tooltip); + button.setMargin(new Insets(1, 7, 1, 7)); + return button; } private static String defaultOverrideSource() { return """ package tools.dscode.workbench.generated; + import tools.dscode.control.api.MappingControl; import tools.dscode.control.override.StepOverrideContext; import tools.dscode.control.override.StepOverrideHandler; @@ -902,8 +1165,8 @@ public final class {{CLASS_NAME}} implements StepOverrideHandler { public Object execute(StepOverrideContext context) { MappingControl.put( "OVERRIDE", - "workbenchStepOverrideValue", - "ui-" + context.captures().getFirst() + "workbenchUiOverrideValue", + context.captures().isEmpty() ? "matched" : context.captures().getFirst() ); return null; } @@ -920,24 +1183,18 @@ public Component getListCellRendererComponent( boolean selected, boolean focus ) { - JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, selected, focus); - LiveScenarioPlayer.Line line = (LiveScenarioPlayer.Line) value; - boolean playhead = index == player.playheadIndex(); - label.setText("%s%3d %s".formatted(playhead ? "▶ " : " ", index + 1, line.text())); - - if (!selected) { - if (line.executionStatus() == LiveScenarioPlayer.ExecutionStatus.EXECUTED) { - label.setForeground(Color.GRAY); - } else if (line.executionStatus() == LiveScenarioPlayer.ExecutionStatus.FAILED) { - label.setForeground(new Color(180, 55, 55)); - } else if (playhead) { - label.setForeground(new Color(40, 140, 70)); - } else if (line.type() == LiveScenarioPlayer.LineType.COMMENT) { - label.setForeground(Color.GRAY); - } + JLabel label = (JLabel) super.getListCellRendererComponent( + list, value, index, selected, focus + ); + if (!(value instanceof LiveScenarioPlayer.Line line)) return label; + + Long activeId = executingStepId; + if (activeId == null && player.state() == LiveScenarioPlayer.State.RUNNING) { + activeId = player.nextStep().map(LiveScenarioPlayer.Line::id).orElse(null); } + String marker = Objects.equals(activeId, line.id()) ? "▶ " : " "; + label.setText(marker + line.text()); return label; } } - } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java index dc71e758..310b80f4 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java @@ -10,6 +10,8 @@ import tools.dscode.control.protocol.ControlBridgeError; import tools.dscode.control.protocol.ControlBridgeEvent; import tools.dscode.control.protocol.ControlBridgeEventPage; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; +import tools.dscode.control.protocol.ControlBridgeMappingSnapshotResult; import tools.dscode.control.protocol.ControlBridgeServiceCallEvidence; import tools.dscode.control.protocol.ControlBridgeServiceCallResult; import tools.dscode.control.protocol.ControlBridgeStatus; @@ -17,13 +19,17 @@ import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; import tools.dscode.control.protocol.ControlBridgeValue; import tools.dscode.control.protocol.ControlBridgeValueResult; +import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.WorkbenchServices; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.Map; +import java.util.Objects; /** Thin presentation adapter over the shared Workbench service surface. */ final class WorkbenchUiController implements AutoCloseable { @@ -78,11 +84,140 @@ State stopWorker() { return state(); } + /** + * One-action player preparation. Existing synchronized state is reused, but a + * protocol-mismatched worker forces one resynchronization and startup retry. + */ + State prepareLiveSession() { + State current = refresh(); + if (!current.synchronizedProject()) { + current = synchronize(); + } + if (!current.workerRunning()) { + try { + current = startWorker(); + } catch (RuntimeException failure) { + if (!isProtocolMismatch(failure)) throw failure; + current = synchronize(); + current = startWorker(); + } + } + if (!current.liveReady()) { + throw new IllegalStateException( + "The Workbench consumer worker did not reach a paused interactive boundary." + ); + } + return current; + } + + /** + * Prepares a fresh interactive scenario context for scenario playback. Existing + * synchronized output is reused, but an active worker is restarted so prior + * browser, Mapping, service, and other scenario side effects do not leak into + * a new Run or From Here action. + */ + State prepareFreshLiveSession() { + State current = refresh(); + if (!current.synchronizedProject()) { + current = synchronize(); + } + try { + current = current.workerRunning() ? restartWorker() : startWorker(); + } catch (RuntimeException failure) { + if (!isProtocolMismatch(failure)) throw failure; + current = synchronize(); + current = startWorker(); + } + if (!current.liveReady()) { + throw new IllegalStateException( + "The Workbench consumer worker did not reach a fresh paused interactive boundary." + ); + } + return current; + } + + private static boolean isProtocolMismatch(Throwable failure) { + Throwable current = failure; + while (current != null) { + String message = current.getMessage(); + if (message != null && message.startsWith("Incompatible control bridge protocol:")) { + return true; + } + current = current.getCause(); + } + return false; + } + LiveActionResult executeStep(String text, String argument) { - ControlBridgeCallResult result = services.executeStep(required(text, "Gherkin step"), blankToNull(argument)); + ControlBridgeCallResult result = services.executeStep( + required(text, "Gherkin step"), + blankToNull(argument) + ); return new LiveActionResult(renderCallResult(result), refreshEvents()); } + PlayerStepResult executePlayerStep(String text) { + ControlBridgeCallResult result = services.executeStep(required(text, "Gherkin step"), ""); + String events = refreshEvents(); + return new PlayerStepResult( + "SUCCESS".equals(result.status()), + renderCallResult(result), + events + ); + } + + List mappingCatalog() { + ControlBridgeMappingSnapshotResult result = services.mappingSnapshot( + ControlProtocol.CURRENT_NODE_MAP_CATALOG_REFERENCE + ); + ControlBridgeMappingSnapshot snapshot = requireSnapshot(result, "NodeMap catalog"); + Object value = snapshot.values().get("maps"); + if (!(value instanceof List list)) { + throw new IllegalStateException("Current ParsingMap catalog did not contain a maps list."); + } + + List entries = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) continue; + String reference = Objects.toString(map.get("reference"), ""); + String label = Objects.toString(map.get("label"), reference); + boolean restorable = Boolean.TRUE.equals(map.get("restorable")); + if (!reference.isBlank()) { + entries.add(new MappingCatalogEntry(reference, label, restorable)); + } + } + return List.copyOf(entries); + } + + ControlBridgeMappingSnapshot mappingSnapshot(String mapReference) { + return requireSnapshot( + services.mappingSnapshot(required(mapReference, "NodeMap reference")), + mapReference + ); + } + + String restoreMapping(ControlBridgeMappingSnapshot original, Map values) { + Objects.requireNonNull(original, "original"); + Objects.requireNonNull(values, "values"); + ControlBridgeMappingSnapshot edited = new ControlBridgeMappingSnapshot( + original.version(), + original.mapReference(), + original.mapType(), + original.mapClass(), + original.dataSources(), + original.restorable(), + values + ); + ControlBridgeCallResult result = services.mappingRestore(edited); + if (!"SUCCESS".equals(result.status())) { + String message = result.error() == null + ? result.status() + : result.error().message(); + throw new IllegalStateException("Mapping restore failed: " + message); + } + return renderCallResult(result); + } + LiveActionResult mappingGet(String mapReference, String key) { ControlBridgeValueResult result = services.mappingGet( required(mapReference, "Mapping reference"), @@ -197,6 +332,19 @@ private State state() { return new State(projectRoot, manifest, synchronizationError, workerStatus); } + private static ControlBridgeMappingSnapshot requireSnapshot( + ControlBridgeMappingSnapshotResult result, + String label + ) { + if (result != null && "SUCCESS".equals(result.status()) && result.snapshot() != null) { + return result.snapshot(); + } + String message = result == null || result.error() == null + ? "no snapshot returned" + : result.error().message(); + throw new IllegalStateException(label + " snapshot failed: " + message); + } + private static String renderCallResult(ControlBridgeCallResult result) { StringBuilder text = new StringBuilder("Status: ").append(result.status()); if (result.valueText() != null) { @@ -392,6 +540,16 @@ private static Integer integerOrNull(String value, String label) { record LiveActionResult(String output, String events) { } + record PlayerStepResult(boolean successful, String output, String events) { + } + + record MappingCatalogEntry(String reference, String label, boolean restorable) { + @Override + public String toString() { + return label; + } + } + record ManagementResult(String output, String listing) { } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java index c5b9870f..29129b7a 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java @@ -5,157 +5,132 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class LiveScenarioPlayerTest { + @Test + void interactiveBufferStartsWithWorkingSmokeSteps() { + LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + + assertEquals(LiveScenarioPlayer.State.STOPPED, player.state()); + player.startFromBeginning(); + assertEquals(" Given ---workbench-player-smoke-1", player.nextStep().orElseThrow().text()); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + } @Test - void selectionAndPlayheadAreIndependentAndIdsStayStable() { + void globalRunAlwaysStartsFromBeginning() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( - "Feature: Demo", - "Scenario: Demo", "Given first", - "And second" + "And second", + "Then third" )); - long firstId = player.lines().get(2).id(); - long secondId = player.lines().get(3).id(); - - player.select(secondId); - LiveScenarioPlayer.Line inserted = player.insertStep("When inserted"); - - assertEquals(secondId, player.selectedId().orElseThrow()); - assertNotEquals(firstId, inserted.id()); - assertNotEquals(secondId, inserted.id()); - assertEquals(inserted.id(), player.nextStep().orElseThrow().id()); - assertEquals(secondId, player.lines().stream() - .filter(line -> line.text().equals("And second")) - .findFirst().orElseThrow().id()); + + player.startFromBeginning(); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + assertEquals("Then third", player.nextStep().orElseThrow().text()); + + player.startFromBeginning(); + assertEquals("Given first", player.nextStep().orElseThrow().text()); } @Test - void insertionOccursAtPlayheadAndBecomesNextStep() { + void fromHereStartsAtSelectedStep() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( "Given first", - "Then second" + "And second", + "Then third" )); - long originalFirst = player.nextStep().orElseThrow().id(); + long second = player.lines().get(1).id(); + player.select(second); - LiveScenarioPlayer.Line inserted = player.insertStep("When inserted"); + player.startFromSelectedStep(); - assertEquals(0, player.playheadIndex()); - assertEquals(inserted.id(), player.nextStep().orElseThrow().id()); - assertEquals(originalFirst, player.lines().get(1).id()); + assertEquals("And second", player.nextStep().orElseThrow().text()); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); } @Test - void runningWaitsAtEndAndNewStepMakesWorkEligibleAgain() { - LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); - player.play(); - long firstId = player.nextStep().orElseThrow().id(); - - player.markCurrentStepExecuted(firstId); - - assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); - assertTrue(player.nextStep().isEmpty()); - - LiveScenarioPlayer.Line added = player.insertStep("And continue"); + void fromHereRequiresExecutableSelection() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Scenario: sample", + "Given first" + )); + player.select(player.lines().getFirst().id()); - assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); - assertEquals(added.id(), player.nextStep().orElseThrow().id()); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + player::startFromSelectedStep + ); + assertTrue(failure.getMessage().contains("executable")); } @Test - void isolatedExecutionRequestLeavesMainPlayerPaused() { + void successfulRunWaitsAtEndAndAddedStepContinues() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); - player.play(); + player.startFromBeginning(); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); - player.pauseForIsolatedExecution(); + assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); - assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + LiveScenarioPlayer.Line added = player.insertStep("And added"); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + assertEquals(added.id(), player.nextStep().orElseThrow().id()); } @Test - void failurePausesOnFailedStepWithoutAdvancing() { + void enterInsertsAfterSelectionAndStepsRemainEditableAfterExecution() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( "Given first", - "Then second" + "Then third" )); - player.play(); - long firstId = player.nextStep().orElseThrow().id(); - - player.markCurrentStepFailed(firstId); - - assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); - assertEquals(firstId, player.nextStep().orElseThrow().id()); - assertEquals(LiveScenarioPlayer.ExecutionStatus.FAILED, player.nextStep().orElseThrow().executionStatus()); - } - - @Test - void updatingPendingSelectionPreservesIdButExecutedStepCannotBeEdited() { - LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); - long id = player.nextStep().orElseThrow().id(); - player.select(id); + long first = player.lines().getFirst().id(); + player.select(first); + LiveScenarioPlayer.Line inserted = player.insertStep("And second"); - LiveScenarioPlayer.Line updated = player.updateSelectedStep("Given changed"); + assertEquals("And second", player.lines().get(1).text()); + player.select(inserted.id()); + player.startFromSelectedStep(); + player.markCurrentStepExecuted(inserted.id()); - assertEquals(id, updated.id()); - assertEquals("Given changed", updated.text()); - - player.play(); - player.markCurrentStepExecuted(id); - assertThrows(IllegalStateException.class, () -> player.updateSelectedStep("Given changed again")); + LiveScenarioPlayer.Line updated = player.updateSelectedStep("And edited second"); + assertEquals("And edited second", updated.text()); } @Test - void nonExecutableLinesAreSkippedWhenAdvancing() { + void failurePausesOnCurrentStep() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( - "Feature: Demo", "Given first", - "# comment", - "", "Then second" )); - player.play(); - long firstId = player.nextStep().orElseThrow().id(); - player.markCurrentStepExecuted(firstId); + player.startFromBeginning(); + long first = player.nextStep().orElseThrow().id(); - assertEquals("Then second", player.nextStep().orElseThrow().text()); - } - @Test - void basicPlayerStateTransitionsAreExplicit() { - LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + player.markCurrentStepFailed(first); - assertEquals(LiveScenarioPlayer.State.STOPPED, player.state()); - player.play(); - assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); - player.pause(); assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); - player.stop(); - assertEquals(LiveScenarioPlayer.State.STOPPED, player.state()); + assertEquals(first, player.nextStep().orElseThrow().id()); } - @Test - void firstAndBackMoveOnlyThePlayhead() { + void insertingIntoCompletedMiddleDoesNotReplayLaterSteps() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( "Given first", "And second", "Then third" )); - long selected = player.lines().get(2).id(); - player.select(selected); - player.play(); - player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); - player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + long first = player.lines().getFirst().id(); + player.startFromBeginning(); + while (player.nextStep().isPresent()) { + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + } + player.select(first); - player.movePlayheadToPreviousStep(); - assertEquals("And second", player.nextStep().orElseThrow().text()); - assertEquals(selected, player.selectedId().orElseThrow()); + player.insertStep("And inserted in middle"); - player.movePlayheadToFirstStep(); - assertEquals("Given first", player.nextStep().orElseThrow().text()); - assertEquals(selected, player.selectedId().orElseThrow()); + assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); + assertTrue(player.nextStep().isEmpty()); } } From 2d9ce26a2ba51412cca4f4625ee80ec3024096ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 03:21:11 +0000 Subject: [PATCH 05/15] Add Workbench player click-to-seek, in-place Gherkin editor, and local-site demo. Global Play still starts from the first step through executeStep. The Step Editor keeps isolated Step play and From Here. Wait-at-end stays in play so Enter appends and continues. Co-authored-by: ds-code-t --- docs/README.md | 1 + docs/agent/feature-map.md | 4 +- docs/agent/repository-index.md | 1 + docs/pickleball-workbench-player.md | 35 +-- docs/pickleball-workbench.md | 110 ++++---- pickleball-workbench/AGENTS.md | 10 +- .../WORKBENCH-PLAYER-CONTEXT.md | 12 +- .../workbench/player/LiveScenarioPlayer.java | 244 +++++++++++++++--- .../dscode/workbench/ui/WorkbenchFrame.java | 227 ++++++++++------ .../player/LiveScenarioPlayerTest.java | 121 +++++++-- .../ui/WorkbenchUiControllerTest.java | 35 +++ .../pickleball/guidance/docs/README.md | 1 + .../guidance/docs/agent/feature-map.md | 4 +- .../guidance/docs/agent/repository-index.md | 1 + .../docs/pickleball-workbench-player.md | 114 ++++++++ .../guidance/docs/pickleball-workbench.md | 110 ++++---- .../META-INF/pickleball/guidance/index.txt | 1 + 17 files changed, 782 insertions(+), 249 deletions(-) create mode 100644 src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md diff --git a/docs/README.md b/docs/README.md index 106dfc6e..6f1ae4cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ When these docs are materialized from the Maven dependency with `DiagnosticCli e - [Dynamic control API](dynamic-control-api.md) — optional retry-friendly dynamic Gherkin execution, isolated/scoped ParsingMap control, snapshots, value interception, synchronous semantic hooks, and the consumer-side Control Bridge used by Workbench. - [Step Overrides](step-overrides.md) — live REGEX/REPLACE step implementation authoring in a persistent worker, including generated Java handlers and Workbench management. - [Pickleball Workbench](pickleball-workbench.md) — dependency-matched external controller embedded opaquely in Pickleball, with a neutral versioned protocol, strict core-free artifact/process boundary, consumer-classpath worker, MCP stdio, and Swing UI. +- [Workbench live player](pickleball-workbench-player.md) — click-to-seek playhead, Play from start, Step vs From Here, wait-at-end add-and-continue, in-place Gherkin editing, and the default local-site demo. - [Diagnostic lineage and metadata](diagnostic-lineage-metadata.md) — distinguish lineage annotations, execution/evidence RunVars, controls, and derived evidence. - [Diagnostic reporting](diagnostic-reporting.md) — sparse-first AI evidence, source provenance, step/capability metadata, trace evidence, screenshots/fingerprints, comparison, and retention. - [AI diagnostic reporting plan](ai-diagnostic-reporting-plan.md) — current sparse-first investigation and controlled-rerun architecture. diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index c5376dae..3ba8c647 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the session buffer, stable line identities, selection, playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. The default buffer is a Workbench-owned browser demo against `URL.home`. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index ebb90e76..9f9807dc 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -47,6 +47,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `docs/key-parser-dsl.md` - `docs/mapping-and-templating.md` - `docs/nested-steps.md` +- `docs/pickleball-workbench-player.md` - `docs/pickleball-workbench.md` - `docs/README.md` - `docs/service-call-scenarios.md` diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md index 05438ea6..f7ca0bb2 100644 --- a/docs/pickleball-workbench-player.md +++ b/docs/pickleball-workbench-player.md @@ -1,6 +1,6 @@ # Pickleball Workbench Live Player -This document describes the live-player behavior implemented by the Workbench UI drop-in for the 2.1.9 branch. +This document describes the live-player behavior implemented by the Workbench UI on the 2.1.9 line. The canonical Workbench guide is [pickleball-workbench.md](pickleball-workbench.md). ## Architecture boundary @@ -21,40 +21,41 @@ consumer worker JVM -> owns Pickleball, Cucumber, DynamicControl, Mapping/ParsingMap/NodeMap, browser, and service behavior ``` -The player and Mapping editor do not change this boundary. +The player and Mapping editor do not change this boundary. Swing is a presentation adapter over `WorkbenchServices` / `WorkbenchController`. Automatic buffered execution and add-and-continue use the existing `executeStep` worker contract; Workbench does not invent a second Gherkin matcher. ## Live Scenario Editor -The left-side editor is a player-oriented scenario buffer. There is no separately user-controlled playhead. The user's selected line is the editing/navigation target; the execution cursor is transient and exists only while a run is active. +The left-side editor is a session-scoped Gherkin text editor with a player playhead. Clicking a line instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. -The initial buffer contains a small working smoke scenario: +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files. The default demo is a small browser scenario against the Maven consumer local test site: ```gherkin Feature: Workbench Live Scenario -Scenario: Quick player smoke test - Given ---workbench-player-smoke-1 - And ---workbench-player-smoke-2 - Then ---workbench-player-smoke-3 +Scenario: Open the local test site + Given navigate to: URL.home + When , ensure "Pickleball Test Lab" Text is displayed + And , click the "Open Forms Playground" Link + Then , ensure "Forms Playground" Text is displayed ``` -Pickleball core already owns the `---...` marker definition as a guaranteed no-op, so this smoke scenario does not depend on consumer-specific glue. +`URL.home` is ordinary consumer config, not a machine-specific filesystem path. Users can edit any line in place, including Gherkin that already executed. Stable line identities are preserved across in-place edits. ### Controls -- The global **Play** button always creates a fresh interactive scenario context and runs from the first executable scenario step. +- Clicking a scenario step instantly moves the playhead to that step. +- The global **Play** button always creates a fresh interactive scenario context and runs from the first executable scenario step, not from the current playhead. - **Pause** prevents the next automatic step from starting. An already in-flight step is allowed to finish. - **Stop** stops automatic advancement but does not kill the consumer worker. Worker lifecycle remains under **Session**. - The Step Editor has two execution actions: - **Step** executes only the Step Editor text against the current paused live context and leaves automatic scenario playback paused. - - **From Here** creates a fresh interactive scenario context and treats the selected executable step as the first step of that run, then continues through the remaining scenario-buffer steps. + - **From Here** creates a fresh interactive scenario context and treats the selected/playhead executable step as the first step of that run, then continues through the remaining buffer. - Fresh scenario playback restarts the consumer worker so browser, Mapping, service, and other side effects from a previous run do not leak into a new **Play** or **From Here** run. -- Reaching the end while playing changes the player to **Waiting for step** rather than stopping. Adding another step while waiting executes it immediately in the current live context. -- **Enter** inserts a new step after the selected line. With no selection, it appends after the last executable scenario step. -- **Ctrl+Enter** updates the selected executable step. Steps remain editable after earlier runs because a later **Play** or **From Here** establishes a fresh scenario context. -- The editor shows only a transient `▶` on the current/next execution line. Successful lines do not retain checkmarks or become grayed out. +- Reaching the end while playing changes the player to **Waiting for step** rather than stopping. Typing a new step and pressing **Enter** appends it to the end of the live scenario and executes it as part of the same live run. +- **Ctrl+Enter** updates the selected line in place. The whole-scenario editor also accepts ordinary typing at any line. +- The editor highlights the current playhead line. Successful lines do not retain checkmarks or become locked. -Selection does not mutate runtime state. Choosing **From Here** is the explicit action that starts a new fresh run from that selected step. +First/Step Back, when present, remain navigation-only and do not rewind runtime side effects. The current player relies on click-to-seek instead of those buttons. ## Full Gherkin line execution @@ -101,6 +102,8 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. +Workbench player/editor unit tests cover click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, and the non-empty browser demo seed. + Workbench changes should continue to use the repository's focused validation policy: ```powershell diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index e0f5d466..d3ba4e3d 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -118,21 +118,20 @@ The Swing UI is a presentation adapter over the same `WorkbenchServices` / `Work The primary workspace is now arranged as an interactive scenario player: ```text -┌─────────────────────────────────────────────────────────────────────┐ -│ Project / readiness ⏮ ◀ ▶ ⏸ ■ Player status │ -├───────────────────────────────┬─────────────────────────────────────┤ -│ LIVE SCENARIO EDITOR │ Mapping | Terminal | Diagnostic Log │ -│ │ │ -│ Feature: ... │ selected right-side workspace │ -│ Scenario: ... │ │ -│ ▶ next playhead step │ │ -│ selected/other line │ │ -├───────────────────────────────┤ │ -│ Step Editor / Command ▶ │ │ -│ [ live command text ] │ │ -└───────────────────────────────┴─────────────────────────────────────┘ -│ Workbench/session activity │ -└─────────────────────────────────────────────────────────────────────┘ ++-------------------------------------------------------------------+ +| Project / readiness Play Pause Stop Player status | ++----------------------------------+--------------------------------+ +| LIVE SCENARIO EDITOR | Mapping | Terminal | Diagnostic| +| (in-place Gherkin text) | | +| Feature: ... | selected right-side workspace | +| Scenario: ... | | +| highlighted playhead step | | ++----------------------------------+ | +| Step Editor [Step] [From Here] | | +| [ live command text ] | | ++----------------------------------+--------------------------------+ +| Workbench/session activity | ++-------------------------------------------------------------------+ ``` The left side contains only the Live Scenario Editor and compact Step Editor / Command. The right side has one tabbed workspace for Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls are available from the **Session** menu. Existing investigation tools are available from **Tools > Advanced Controls** so the underlying capabilities are preserved without dominating the normal workflow. @@ -142,51 +141,67 @@ The left side contains only the Live Scenario Editor and compact Step Editor / C `LiveScenarioPlayer` is a headless Workbench-side presentation model. It owns only: - stable line identities independent of display line number; -- the live session buffer; +- the live session buffer as editable Gherkin text; - selected line; -- playhead insertion point; -- pending/executed/failed presentation status; +- playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -Selection and playhead are independent. Selecting another line does not move the playhead. New Step Editor commands are inserted at the playhead insertion point, not at the text caret or selected line. +The Live Scenario Editor is a regular in-place Gherkin editor. Users can type at any line, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. The buffer is a session document; Workbench does not write it back to consumer `.feature` files. -The initial buffer is an interactive session buffer; it is not automatically written back to consumer `.feature` files. +The playhead behaves like an audio-player needle: -Step Editor gestures are intentionally explicit: +- clicking a scenario line instantly seeks the playhead to that line; +- while a run is active, the playhead follows the current/next executable step after each success; +- **Pause** and **Stop** do not claim to rewind browser, Mapping, service, or other worker side effects. + +Global **Play** always starts a fresh interactive scenario context and runs from the first executable step, even if the playhead is elsewhere. Fresh **Play** / **From Here** runs restart the consumer worker so prior side effects do not masquerade as the start of a scenario. + +### Step Editor play actions + +The Step Editor exposes two distinct play actions on the same `WorkbenchServices.executeStep` seam used by MCP: ```text -Enter insert a new command at the playhead -Ctrl+Enter update the selected pending executable step -▶ execute the Step Editor text in isolation through the existing live service +▶ Step execute only the Step Editor text in the current paused live context +▶ From Here start a fresh scenario context and run from the selected/playhead step through the rest of the buffer +Enter insert a step (append-and-run while waiting at end) +Ctrl+Enter update the selected line in place ``` -Executed or failed buffer steps cannot be edited in place in this phase because the UI must not imply that browser, service, or other external side effects were undone. +**Step** pauses automatic playback, sends the displayed Gherkin unchanged, and leaves the main player paused. **From Here** treats the selected executable step as the first step of a new run. -The **First** and **Step Back** controls are navigation-only in this phase. They do not claim to rewind Pickleball runtime state or undo external side effects. +Workbench never strips `Given` / `When` / `Then` / `And` / `But` / `*` and does not contain a second Gherkin matcher. If a displayed line starts with one of those keywords, worker-side `DynamicControl` parses that one line through `GherkinControl` and executes the resulting detached step. Historical raw detached-step text remains supported. -### Phase 1 execution boundary +### Stay in play / add-and-continue -The current player-style increment establishes the new layout and headless player/buffer state without inventing new Pickleball runtime semantics. +Reaching the end of the buffer does not drop out of play. The player remains `WAITING_FOR_STEP`. Typing a new step in the Step Editor and pressing **Enter** appends that step to the end of the live scenario and queues it through the same `executeStep` contract. Adding an executable line at the end of the in-place editor while waiting does the same. Inserting a line earlier in the document does not replay later steps. -The main Play/Pause/Stop controls currently update the buffer/player state model only. Automatic buffered execution, wait-at-end execution, and add-and-continue behavior are the next implementation phase. That loop must be wired through an explicit Pickleball-owned Gherkin/runtime contract; Swing must not strip `Given`/`When`/`Then`, create a second step matcher, or otherwise guess how displayed Gherkin maps to detached step execution. +### Default demo scenario -The small Step Editor Play button continues to use the existing `WorkbenchServices.executeStep` contract unchanged. It pauses the main player state before isolated execution and does not automatically resume it afterward. +A new Workbench session loads a Workbench-owned sample, not a blank buffer and not a consumer `.feature` file. The default scenario is a small browser demo against the existing Maven consumer local test site: -### Mapping tab +```gherkin +Feature: Workbench Live Scenario -There is no GUI-defined `Current Scope` concept. +Scenario: Open the local test site + Given navigate to: URL.home + When , ensure "Pickleball Test Lab" Text is displayed + And , click the "Open Forms Playground" Link + Then , ensure "Forms Playground" Text is displayed +``` + +`URL.home` comes from the consumer's ordinary config mapping. The sample does not hard-code machine-specific filesystem paths. Once a worker is up, **Play** exercises real browser navigation and a click on the local test site. -The target Mapping design is a single NodeMap selector populated from the real `ParsingMap` associated with the selected step, with common NodeMaps remaining available across the live scenario. The GUI must not hard-code names or recreate inheritance rules. +### Mapping tab -The Phase 1 UI therefore leaves the NodeMap selector unavailable until the required Pickleball-side ParsingMap inspection contract exists. Existing Mapping get/put/resolve controls remain available as compatibility controls. The Swing Mapping put control continues to send entered values as text; MCP continues to support arbitrary JSON-compatible Mapping values through the shared service method. +There is no GUI-defined `Current Scope` concept. The Mapping tab is one NodeMap selector populated from the current worker-side `ParsingMap`, plus one JSON object editor for the selected NodeMap root. Valid JSON edits are restored through the existing `mappingRestore` service. The GUI must not hard-code NodeMap names or recreate inheritance rules. -Structural NodeMap browsing/mutation is a later phase and must be implemented against real worker-side Pickleball state. +NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only. MCP continues to support arbitrary JSON-compatible Mapping values through the shared service methods. ### Terminal and Diagnostic Log Explorer The Terminal tab currently displays Workbench UI activity only. Worker log streaming, level filtering, search, and auto-scroll belong to the Terminal phase and must use the appropriate worker/Workbench logging source without violating the MCP stdout contract. -The Diagnostic Log Explorer tab is intentionally a placeholder in Phase 1. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: +The Diagnostic Log Explorer tab remains a placeholder until it is bound to Pickleball's retained diagnostic artifacts. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: 1. `run-catalog.json` 2. selected `run-index.json` / `clusters.json` @@ -218,7 +233,7 @@ The Step Override editor sends its source template unchanged to the worker. The Synchronization, worker actions, live bridge calls, Mapping operations, event refresh, Step Override actions, browser/screenshot evidence, service calls, and breakpoint actions run off the Swing Event Dispatch Thread. Live controls are enabled only while the Workbench-owned worker is running and paused. -The UI is intentionally not a project IDE, general feature-file editor, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. +The UI is intentionally not a project IDE, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. The Live Scenario Editor is a session-scoped Gherkin player/editor, not a workspace file explorer and not an automatic writer of consumer `.feature` files. ## MCP stdio @@ -336,7 +351,7 @@ io.modelcontextprotocol.sdk:mcp-core:2.0.0 io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0 ``` -## Manual UI acceptance for the Phase 1 player foundation +## Manual UI acceptance for the live player/editor ```powershell $workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" @@ -346,14 +361,17 @@ java -jar $workbenchJar ui ".\maven-consumer-project" Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. 1. Verify the top-level layout has the Live Scenario Editor and compact Step Editor on the left, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. -2. Insert multiple commands with Enter and verify each is inserted at the visible playhead while selection can remain on another line. -3. Select a pending command, change its text, press Ctrl+Enter, and verify its displayed line updates without changing its stable position semantics. -4. Use First and Step Back and verify the playhead indicator moves independently from the selection. Treat these as navigation-only; no runtime rewind is claimed. -5. Use Play/Pause/Stop and verify player presentation states, including `Waiting for next step...` when Play has no next buffered command. Do not treat this as Phase 2 automatic runtime execution. -6. Open **Session**, synchronize/start a worker, select or enter a valid existing live raw Gherkin command, click the small Step Editor Play button, and verify it delegates isolated execution and leaves the main player paused. -7. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Existing get/put/resolve controls remain usable with a paused worker. -8. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. -9. Verify blocking runtime actions leave the Swing UI responsive. +2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site. +3. Click different scenario steps and verify the playhead highlight moves immediately to the clicked line. +4. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. +5. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. +6. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. +7. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. +8. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. +9. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. +10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. The NodeMap JSON editor remains usable with a paused worker. +11. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +12. Verify blocking runtime actions leave the Swing UI responsive. ## Regression diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index c81ba944..db357b44 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -72,17 +72,17 @@ right: Mapping | Terminal | Diagnostic Log Explorer Low-level lifecycle controls live under the Session menu and existing investigation controls remain available under Advanced Controls rather than dominating the permanent workspace. -`LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, selected line, playhead insertion point, pending/executed/failed visual status, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. +`LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, the editable Gherkin document, selected line, playhead, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. -Selection and playhead are separate. Inserting a Step Editor command occurs at the playhead insertion point and does not depend on the selected line. Enter inserts a new command; Ctrl+Enter updates the selected pending step. Already executed or failed buffer steps are not edited in place because the UI must not imply that browser/service/external side effects were undone. +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document; users may edit any line, including previously executed text. The default buffer is a Workbench-owned browser demo against `URL.home` and is not written back to consumer `.feature` files. -The small Step Editor Play button delegates the text unchanged through the existing `WorkbenchUiController.executeStep` / `WorkbenchServices.executeStep` path and leaves the main player paused. Do not strip Gherkin keywords or add a Swing-side step matcher. A future main player execution loop must use an explicit Pickleball-owned Gherkin/runtime contract rather than guessing how display lines map to detached step text. +The Step Editor has two play actions: **Step** executes only the editor text through `WorkbenchServices.executeStep` and leaves automatic playback paused; **From Here** restarts into a fresh scenario context and runs from the selected/playhead step through the rest of the buffer. Enter while waiting at end appends the step and continues the live run. Do not strip Gherkin keywords or add a Swing-side step matcher. Worker-side `DynamicControl` / `GherkinControl` remain the only Gherkin interpreters. ### Current player implementation phase -The first player-style increment intentionally establishes presentation/state only. The main Play/Pause/Stop controls currently drive the headless buffer/player state; automatic buffered runtime execution belongs to the next phase after a safe Pickleball-owned full-Gherkin execution contract is selected and tested. +Buffered Play / From Here / add-and-continue now execute through the existing live `executeStep` contract. Swing remains a presentation adapter: it sends displayed Gherkin unchanged and never owns a second worker manager, Mapping implementation, or Pickleball runtime. -The Mapping tab must not hard-code NodeMap names. Until the ParsingMap-aware bridge/service contract is implemented, the NodeMap selector remains unavailable and the existing Mapping get/put/resolve controls remain as compatibility controls. Do not create a fake ParsingMap in Swing. +The Mapping tab must not hard-code NodeMap names. It is one current-ParsingMap NodeMap selector plus a JSON object editor restored through `mappingRestore`. Do not create a fake ParsingMap in Swing. The Terminal tab currently shows Workbench UI activity only. Actual worker log streaming/filtering is a separate phase and must not be implemented by redirecting MCP stdout. The Diagnostic Log Explorer is also a placeholder until it is bound to Pickleball's existing retained diagnostic artifacts and evidence-escalation model. Do not populate either tab with fake production data. diff --git a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md index a9fa9670..69450f3b 100644 --- a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md +++ b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md @@ -8,20 +8,22 @@ This file records the live-player behavior added on top of those unchanged bound `LiveScenarioPlayer` is presentation/buffer state only. Pickleball execution remains in the consumer worker. -There is no user-editable playhead. Selection is the editor/navigation target; the execution cursor is internal and transient. +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it, like clicking a waveform. Global Play ignores the playhead and always starts from the first executable buffer step in a fresh interactive worker context. -The global Play action always starts from the first executable buffer step in a fresh interactive worker context. The Step Editor exposes two distinct execution actions: +The Live Scenario Editor is an in-place Gherkin document. Users can type at any line, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files. + +The Step Editor exposes two distinct execution actions: - **Step**: execute only the editor text in the current paused live context and leave automatic playback paused. -- **From Here**: restart into a fresh interactive scenario context, use the selected executable step as the first step of the run, and continue through the remaining buffer steps. +- **From Here**: restart into a fresh interactive scenario context, use the selected/playhead executable step as the first step of the run, and continue through the remaining buffer steps. Fresh Play/From Here runs restart the worker so prior browser, Mapping, service, or other side effects do not masquerade as the beginning of a scenario. Protocol-mismatched synchronized state still triggers the existing one-time resynchronization retry. Pause stops advancement after any current in-flight command. Stop stops automatic player advancement but does not imply runtime rewind and does not terminate the worker. -At end-of-buffer, automatic playback remains `WAITING_FOR_STEP`. Enter inserts after the selected line (or after the last executable step when nothing is selected); adding a step while waiting resumes execution. Ctrl+Enter updates the selected executable step regardless of whether it was executed in an earlier run. +At end-of-buffer, automatic playback remains `WAITING_FOR_STEP`. Enter appends after the last executable step while waiting and resumes execution. Adding an executable line at the end of the document while waiting does the same. Ctrl+Enter and ordinary typing update lines in place regardless of whether they were executed in an earlier run. -Successful execution is not retained as permanent checkmarks or gray styling. The scenario editor shows only a transient current-execution marker; failure detail remains available through selection, status, and Terminal output. +The default loaded scenario is a Workbench-owned browser demo against `URL.home` and the consumer local test site. It is not a blank buffer and does not hard-code machine-specific paths. The Workbench sends displayed Gherkin unchanged. Full `Given`/`When`/`Then`/`And`/`But`/`*` interpretation is implemented in worker-side `DynamicControl`; never move keyword stripping or Cucumber parsing into Workbench. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java index bff40df3..2d506a9e 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java @@ -5,13 +5,15 @@ import java.util.Objects; import java.util.Optional; import java.util.OptionalLong; +import java.util.stream.Collectors; /** * Headless presentation model for the Workbench live scenario buffer. * - *

The editor selection is the user's navigation model. The execution cursor - * is internal and exists only while a run is active; it is not a separately - * editable playhead.

+ *

The playhead is the user-visible needle: clicking a line seeks immediately, + * like clicking a waveform. Global Play ignores the playhead and always starts + * from the first executable step. Isolated Step Editor play leaves this model + * paused. Real Gherkin matching and execution stay in the consumer worker.

*/ public final class LiveScenarioPlayer { public enum State { @@ -40,9 +42,27 @@ public boolean executable() { } } + /** + * Workbench-owned demo buffer. Steps use consumer config keys such as + * {@code URL.home}, not machine-specific filesystem paths. + */ + public static final List DEFAULT_DEMO_SCENARIO = List.of( + "Feature: Workbench Live Scenario", + "", + "Scenario: Open the local test site", + " Given navigate to: URL.home", + " When , ensure \"Pickleball Test Lab\" Text is displayed", + " And , click the \"Open Forms Playground\" Link", + " Then , ensure \"Forms Playground\" Text is displayed", + "", + "# Click a step to move the playhead. Global Play always starts from the first step." + ); + private final List lines = new ArrayList<>(); private long nextId = 1; private Long selectedId; + private Long playheadId; + private Long lastExecutedId; private int executionIndex; private State state = State.STOPPED; @@ -52,27 +72,22 @@ public LiveScenarioPlayer(List initialLines) { addInitialLine(text == null ? "" : text); } } - executionIndex = findNextExecutableIndex(0); + initializeCursors(); } - /** A consumer-independent Pickleball-core smoke scenario. */ + /** Default live buffer: a small browser demo against the consumer local test site. */ public static LiveScenarioPlayer interactiveBuffer() { - return new LiveScenarioPlayer(List.of( - "Feature: Workbench Live Scenario", - "", - "Scenario: Quick player smoke test", - " Given ---workbench-player-smoke-1", - " And ---workbench-player-smoke-2", - " Then ---workbench-player-smoke-3", - "", - "# Global Play starts fresh from the first step. Select a step for From Here." - )); + return new LiveScenarioPlayer(DEFAULT_DEMO_SCENARIO); } public List lines() { return List.copyOf(lines); } + public String documentText() { + return lines.stream().map(Line::text).collect(Collectors.joining("\n")); + } + public State state() { return state; } @@ -82,8 +97,15 @@ public OptionalLong selectedId() { } public Optional selectedLine() { - if (selectedId == null) return Optional.empty(); - return lines.stream().filter(line -> line.id() == selectedId).findFirst(); + return line(selectedId); + } + + public OptionalLong playheadId() { + return playheadId == null ? OptionalLong.empty() : OptionalLong.of(playheadId); + } + + public Optional playheadLine() { + return line(playheadId); } public Optional nextStep() { @@ -92,6 +114,16 @@ public Optional nextStep() { return line.executable() ? Optional.of(line) : Optional.empty(); } + /** + * Audio-player seek: clicking a line instantly moves the playhead and + * makes that line the editor selection. + */ + public void clickLine(long id) { + requireLineIndex(id); + selectedId = id; + playheadId = id; + } + public void select(long id) { requireLineIndex(id); selectedId = id; @@ -103,55 +135,118 @@ public void clearSelection() { /** Starts a new buffer run at the first executable step. */ public void startFromBeginning() { + lastExecutedId = null; executionIndex = findNextExecutableIndex(0); state = executionIndex < lines.size() ? State.RUNNING : State.WAITING_FOR_STEP; + if (executionIndex < lines.size()) { + playheadId = lines.get(executionIndex).id(); + } } /** Starts a new buffer run at the selected executable step. */ public void startFromSelectedStep() { - Line selected = selectedLine().orElseThrow(() -> - new IllegalStateException("Select a scenario step to run from here.")); + Line selected = selectedLine().orElseGet(() -> playheadLine().orElseThrow(() -> + new IllegalStateException("Select a scenario step to run from here."))); if (!selected.executable()) { throw new IllegalStateException("Select an executable scenario step to run from here."); } + lastExecutedId = null; executionIndex = requireLineIndex(selected.id()); + selectedId = selected.id(); + playheadId = selected.id(); state = State.RUNNING; } /** - * Inserts a new command directly after the selected line. With no selection, - * it is appended after the last executable scenario step. + * Inserts a new command. While waiting at end-of-buffer, the step is + * appended and playback continues. Otherwise it is inserted after the + * selected line, or after the last executable step when nothing is selected. */ public Line insertStep(String text) { String stepText = requiredText(text, "Step"); - int insertAt = insertionIndex(); int appendAt = insertionAfterLastExecutable(); + int insertAt = state == State.WAITING_FOR_STEP ? appendAt : insertionIndex(); Line inserted = new Line(nextId++, stepText, LineType.STEP); lines.add(insertAt, inserted); + selectedId = inserted.id(); + playheadId = inserted.id(); if (state == State.WAITING_FOR_STEP && insertAt == appendAt) { executionIndex = insertAt; state = State.RUNNING; - } else if (insertAt < executionIndex) { + } else if (insertAt <= executionIndex && executionIndex < lines.size()) { executionIndex++; } return inserted; } - /** Updates the selected executable step while preserving its stable id. */ + /** Updates the selected line in place while preserving its stable id. */ public Line updateSelectedStep(String text) { - String stepText = requiredText(text, "Step"); Line selected = selectedLine().orElseThrow(() -> - new IllegalStateException("Select an executable step to update.")); - if (!selected.executable()) { - throw new IllegalStateException("Only executable scenario steps can be updated."); - } - int index = requireLineIndex(selected.id()); - Line updated = new Line(selected.id(), stepText, LineType.STEP); + new IllegalStateException("Select a scenario line to update.")); + return updateLine(selected.id(), text); + } + + /** + * In-place edit of any buffer line, including previously executed Gherkin. + * Stable identity is preserved; classification follows the new text. + */ + public Line updateLine(long id, String text) { + int index = requireLineIndex(id); + String value = text == null ? "" : text; + Line updated = new Line(id, value, classify(value)); lines.set(index, updated); + if (executionIndex == index && !updated.executable() && state == State.RUNNING) { + executionIndex = findNextExecutableIndex(index + 1); + if (executionIndex >= lines.size()) { + state = State.WAITING_FOR_STEP; + } + } return updated; } + /** + * Replaces the whole document while preserving stable ids for lines that + * stay at the same index, and LCS-matched lines when the line count changes. + * Appending an executable step while waiting resumes playback. + */ + public void replaceDocument(List texts) { + List incoming = normalizeDocument(texts); + boolean waiting = state == State.WAITING_FOR_STEP; + Long previousPlayhead = playheadId; + Long previousSelected = selectedId; + Long previousExecId = executionIndex < lines.size() ? lines.get(executionIndex).id() : null; + + List rebuilt = alignLines(List.copyOf(lines), incoming); + lines.clear(); + lines.addAll(rebuilt); + + playheadId = present(previousPlayhead) ? previousPlayhead : defaultPlayheadId(); + selectedId = present(previousSelected) ? previousSelected : null; + + if (previousExecId != null && present(previousExecId)) { + executionIndex = requireLineIndex(previousExecId); + if (executionIndex < lines.size() && !lines.get(executionIndex).executable()) { + executionIndex = findNextExecutableIndex(executionIndex + 1); + } + } else if (waiting || state == State.RUNNING) { + executionIndex = nextExecutableAfter(lastExecutedId); + } else { + executionIndex = findNextExecutableIndex(0); + } + + if (waiting) { + int next = nextExecutableAfter(lastExecutedId); + if (next < lines.size()) { + executionIndex = next; + state = State.RUNNING; + playheadId = lines.get(next).id(); + } else { + executionIndex = lines.size(); + } + } + } + public void pause() { if (state == State.RUNNING || state == State.WAITING_FOR_STEP) { state = State.PAUSED; @@ -167,11 +262,14 @@ public void pauseForIsolatedExecution() { state = State.PAUSED; } - /** Advances a successful run to the next executable line. */ + /** Advances a successful run to the next executable line and stays in play at end. */ public void markCurrentStepExecuted(long stepId) { int index = requireCurrentStep(stepId); + lastExecutedId = stepId; executionIndex = findNextExecutableIndex(index + 1); - if (state == State.RUNNING && executionIndex >= lines.size()) { + if (executionIndex < lines.size()) { + playheadId = lines.get(executionIndex).id(); + } else if (state == State.RUNNING) { state = State.WAITING_FOR_STEP; } } @@ -179,13 +277,37 @@ public void markCurrentStepExecuted(long stepId) { /** Leaves a failed run paused on its failed line. */ public void markCurrentStepFailed(long stepId) { executionIndex = requireCurrentStep(stepId); + playheadId = stepId; + selectedId = stepId; state = State.PAUSED; } + private void initializeCursors() { + executionIndex = findNextExecutableIndex(0); + playheadId = defaultPlayheadId(); + } + + private Long defaultPlayheadId() { + if (executionIndex < lines.size()) return lines.get(executionIndex).id(); + return lines.isEmpty() ? null : lines.getFirst().id(); + } + + private Optional line(Long id) { + if (id == null) return Optional.empty(); + return lines.stream().filter(line -> line.id() == id).findFirst(); + } + + private boolean present(Long id) { + return id != null && line(id).isPresent(); + } + private int insertionIndex() { if (selectedId != null) { return requireLineIndex(selectedId) + 1; } + if (playheadId != null) { + return requireLineIndex(playheadId) + 1; + } return insertionAfterLastExecutable(); } @@ -196,6 +318,13 @@ private int insertionAfterLastExecutable() { return lines.size(); } + private int nextExecutableAfter(Long afterId) { + if (afterId != null && present(afterId)) { + return findNextExecutableIndex(requireLineIndex(afterId) + 1); + } + return findNextExecutableIndex(0); + } + private void addInitialLine(String text) { lines.add(new Line(nextId++, text, classify(text))); } @@ -222,6 +351,55 @@ private int requireLineIndex(long id) { throw new IllegalArgumentException("Unknown live scenario line id: " + id); } + private static List normalizeDocument(List texts) { + if (texts == null || texts.isEmpty()) return List.of(""); + List incoming = new ArrayList<>(texts.size()); + for (String text : texts) { + incoming.add(text == null ? "" : text); + } + return incoming; + } + + private List alignLines(List previous, List incoming) { + if (previous.size() == incoming.size()) { + List updated = new ArrayList<>(incoming.size()); + for (int i = 0; i < incoming.size(); i++) { + updated.add(new Line(previous.get(i).id(), incoming.get(i), classify(incoming.get(i)))); + } + return updated; + } + + int n = previous.size(); + int m = incoming.size(); + int[][] dp = new int[n + 1][m + 1]; + for (int i = n - 1; i >= 0; i--) { + for (int j = m - 1; j >= 0; j--) { + if (previous.get(i).text().equals(incoming.get(j))) { + dp[i][j] = 1 + dp[i + 1][j + 1]; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + List result = new ArrayList<>(m); + int i = 0; + int j = 0; + while (j < m) { + if (i < n && previous.get(i).text().equals(incoming.get(j))) { + result.add(new Line(previous.get(i).id(), incoming.get(j), classify(incoming.get(j)))); + i++; + j++; + } else if (i < n && (j >= m || dp[i + 1][j] >= dp[i][j + 1])) { + i++; + } else { + result.add(new Line(nextId++, incoming.get(j), classify(incoming.get(j)))); + j++; + } + } + return result; + } + private static String requiredText(String value, String label) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(label + " must not be blank."); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index 431dc1e6..ca39b5dc 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -9,8 +9,13 @@ import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultHighlighter; +import javax.swing.text.Highlighter; import java.awt.*; import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.util.List; @@ -28,8 +33,10 @@ final class WorkbenchFrame extends JFrame { private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); private final ObjectMapper json = new ObjectMapper(); - private final DefaultListModel scenarioModel = new DefaultListModel<>(); - private final JList scenarioList = new JList<>(scenarioModel); + private static final Color PLAYHEAD_COLOR = new Color(255, 228, 150); + private final JTextArea scenarioEditor = new JTextArea(); + private final Highlighter.HighlightPainter playheadPainter = + new DefaultHighlighter.DefaultHighlightPainter(PLAYHEAD_COLOR); private final JTextField stepText = new JTextField(); private final JButton playButton = playerButton("▶", "Run the scenario from the first step in a fresh scenario context"); @@ -75,7 +82,7 @@ final class WorkbenchFrame extends JFrame { private boolean pendingFreshRun; private Long pendingFreshRunStepId; private String pendingIsolatedStep; - private boolean syncingScenarioSelection; + private boolean syncingScenarioDocument; private boolean closing; WorkbenchFrame(WorkbenchUiController controller) { @@ -182,15 +189,15 @@ private JComponent scenarioPanel() { JPanel panel = new JPanel(new BorderLayout(0, 4)); panel.setBorder(BorderFactory.createTitledBorder("Live Scenario Editor")); - scenarioList.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); - scenarioList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - scenarioList.setFixedCellHeight(26); - scenarioList.setCellRenderer(new ScenarioRenderer()); - panel.add(new JScrollPane(scenarioList), BorderLayout.CENTER); + scenarioEditor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + scenarioEditor.setLineWrap(false); + scenarioEditor.setTabSize(2); + panel.add(new JScrollPane(scenarioEditor), BorderLayout.CENTER); JPanel legend = new JPanel(new FlowLayout(FlowLayout.LEFT, 18, 2)); - legend.add(new JLabel("▶ Current execution")); - legend.add(new JLabel("Selection = edit / From Here")); + legend.add(new JLabel("Click a step to move the playhead")); + legend.add(new JLabel("Global Play starts from the first step")); + legend.add(new JLabel("Edit Gherkin in place")); panel.add(legend, BorderLayout.SOUTH); return panel; } @@ -208,7 +215,7 @@ private JComponent stepPanel() { header.add(title); header.add(stepOnlyButton); header.add(fromHereButton); - header.add(new JLabel("Enter = insert after selection Ctrl+Enter = update selected step")); + header.add(new JLabel("Enter = append/insert Ctrl+Enter = update selected line")); panel.add(header, BorderLayout.NORTH); stepText.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); @@ -290,19 +297,28 @@ private JPanel footer() { } private void configureScenarioEditor() { - scenarioList.addListSelectionListener(event -> { - if (event.getValueIsAdjusting() || syncingScenarioSelection) return; - LiveScenarioPlayer.Line selected = scenarioList.getSelectedValue(); - if (selected == null) { - player.clearSelection(); - updateFromHereAvailability(); - return; + scenarioEditor.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent event) { + scenarioDocumentChanged(); } - player.select(selected.id()); - if (selected.executable()) { - stepText.setText(selected.text()); + + @Override + public void removeUpdate(DocumentEvent event) { + scenarioDocumentChanged(); + } + + @Override + public void changedUpdate(DocumentEvent event) { + scenarioDocumentChanged(); + } + }); + scenarioEditor.addCaretListener(event -> seekPlayheadToCaret()); + scenarioEditor.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + seekPlayheadToCaret(); } - updateFromHereAvailability(); }); } @@ -387,7 +403,9 @@ private void runScenarioFromBeginning() { } private void runScenarioFromSelectedStep() { - LiveScenarioPlayer.Line selected = player.selectedLine().orElse(null); + LiveScenarioPlayer.Line selected = player.selectedLine() + .or(player::playheadLine) + .orElse(null); if (selected == null || !selected.executable()) { showFailure("Could not run from selected step", new IllegalStateException("Select an executable scenario step first.")); @@ -416,9 +434,9 @@ private void startFreshRunNow(Long startStepId) { player.startFromBeginning(); updatePlayerView("Starting a fresh scenario run from the first step..."); } else { - player.select(startStepId); + player.clickLine(startStepId); player.startFromSelectedStep(); - selectLine(startStepId); + showLine(startStepId); updatePlayerView("Starting a fresh scenario run from the selected step..."); } } catch (RuntimeException failure) { @@ -504,8 +522,8 @@ private void schedulePlaybackStep() { player.markCurrentStepExecuted(step.id()); } else { player.markCurrentStepFailed(step.id()); - player.select(step.id()); - selectLine(step.id()); + player.clickLine(step.id()); + showLine(step.id()); } syncScenarioView(); updatePlayerView( @@ -525,9 +543,9 @@ private void schedulePlaybackStep() { playbackBusy = false; executingStepId = null; player.markCurrentStepFailed(step.id()); - player.select(step.id()); + player.clickLine(step.id()); syncScenarioView(); - selectLine(step.id()); + showLine(step.id()); updatePlayerView("Step execution failed. Scenario playback paused."); showFailure("Could not execute live step", failure); if (!runPendingFreshRun()) runPendingIsolatedStep(); @@ -539,10 +557,14 @@ private void insertStep() { try { LiveScenarioPlayer.Line inserted = player.insertStep(stepText.getText()); stepText.setText(""); - player.select(inserted.id()); + player.clickLine(inserted.id()); syncScenarioView(); - selectLine(inserted.id()); - updatePlayerView("Inserted step after the selected line."); + showLine(inserted.id()); + updatePlayerView( + player.state() == LiveScenarioPlayer.State.RUNNING + ? "Appended step and continued live playback." + : "Inserted step into the live scenario." + ); if (player.state() == LiveScenarioPlayer.State.RUNNING) { if (lastState == null || !lastState.liveReady()) { prepareLiveSession(this::schedulePlaybackStep); @@ -559,8 +581,8 @@ private void updateSelectedStep() { try { LiveScenarioPlayer.Line updated = player.updateSelectedStep(stepText.getText()); syncScenarioView(); - selectLine(updated.id()); - updatePlayerView("Updated selected step."); + showLine(updated.id()); + updatePlayerView("Updated selected line in place."); } catch (RuntimeException failure) { showFailure("Could not update selected step", failure); } @@ -768,40 +790,118 @@ private void setMappingEditor(String text, boolean editable) { } } + private void scenarioDocumentChanged() { + if (syncingScenarioDocument) return; + player.replaceDocument(List.of(scenarioEditor.getText().split("\n", -1))); + seekPlayheadToCaret(); + updateFromHereAvailability(); + refreshPlayheadHighlight(); + if (player.state() == LiveScenarioPlayer.State.RUNNING) { + if (lastState == null || !lastState.liveReady()) { + prepareLiveSession(this::schedulePlaybackStep); + } else { + schedulePlaybackStep(); + } + } + } + + private void seekPlayheadToCaret() { + if (syncingScenarioDocument) return; + int lineIndex = lineIndexAtCaret(); + List lines = player.lines(); + if (lineIndex < 0 || lineIndex >= lines.size()) { + updateFromHereAvailability(); + refreshPlayheadHighlight(); + return; + } + LiveScenarioPlayer.Line line = lines.get(lineIndex); + player.clickLine(line.id()); + if (line.executable()) { + stepText.setText(line.text()); + } + updateFromHereAvailability(); + refreshPlayheadHighlight(); + } + private void syncScenarioView() { - Long selected = player.selectedId().isPresent() - ? player.selectedId().getAsLong() - : null; - syncingScenarioSelection = true; - try { - scenarioModel.clear(); - for (LiveScenarioPlayer.Line line : player.lines()) { - scenarioModel.addElement(line); + String document = player.documentText(); + if (!Objects.equals(scenarioEditor.getText(), document)) { + syncingScenarioDocument = true; + try { + int caret = Math.min(scenarioEditor.getCaretPosition(), document.length()); + scenarioEditor.setText(document); + scenarioEditor.setCaretPosition(Math.max(0, caret)); + } finally { + syncingScenarioDocument = false; } - if (selected != null) selectLine(selected); - } finally { - syncingScenarioSelection = false; } - scenarioList.repaint(); + refreshPlayheadHighlight(); updateFromHereAvailability(); } private void updateFromHereAvailability() { fromHereButton.setEnabled( - player.selectedLine().map(LiveScenarioPlayer.Line::executable).orElse(false) + player.selectedLine() + .or(player::playheadLine) + .map(LiveScenarioPlayer.Line::executable) + .orElse(false) ); } - private void selectLine(long id) { - for (int i = 0; i < scenarioModel.size(); i++) { - if (scenarioModel.get(i).id() == id) { - scenarioList.setSelectedIndex(i); - scenarioList.ensureIndexIsVisible(i); + private void showLine(long id) { + List lines = player.lines(); + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).id() == id) { + try { + int start = scenarioEditor.getLineStartOffset(i); + syncingScenarioDocument = true; + try { + scenarioEditor.setCaretPosition(start); + } finally { + syncingScenarioDocument = false; + } + scenarioEditor.getCaret().setVisible(true); + } catch (BadLocationException ignored) { + // The document can briefly lag the model during a rebuild. + } + refreshPlayheadHighlight(); return; } } } + private int lineIndexAtCaret() { + try { + return scenarioEditor.getLineOfOffset(scenarioEditor.getCaretPosition()); + } catch (BadLocationException ignored) { + return -1; + } + } + + private void refreshPlayheadHighlight() { + Highlighter highlighter = scenarioEditor.getHighlighter(); + highlighter.removeAllHighlights(); + Long playhead = player.playheadId().isPresent() ? player.playheadId().getAsLong() : null; + if (playhead == null && executingStepId != null) playhead = executingStepId; + if (playhead == null && player.state() == LiveScenarioPlayer.State.RUNNING) { + playhead = player.nextStep().map(LiveScenarioPlayer.Line::id).orElse(null); + } + if (playhead == null) return; + + List lines = player.lines(); + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).id() != playhead) continue; + try { + int start = scenarioEditor.getLineStartOffset(i); + int end = scenarioEditor.getLineEndOffset(i); + highlighter.addHighlight(start, end, playheadPainter); + } catch (BadLocationException ignored) { + return; + } + return; + } + } + private void updatePlayerView(String activity) { playerStatusLabel.setText(switch (player.state()) { case STOPPED -> "Stopped"; @@ -1174,27 +1274,4 @@ public Object execute(StepOverrideContext context) { """; } - private final class ScenarioRenderer extends DefaultListCellRenderer { - @Override - public Component getListCellRendererComponent( - JList list, - Object value, - int index, - boolean selected, - boolean focus - ) { - JLabel label = (JLabel) super.getListCellRendererComponent( - list, value, index, selected, focus - ); - if (!(value instanceof LiveScenarioPlayer.Line line)) return label; - - Long activeId = executingStepId; - if (activeId == null && player.state() == LiveScenarioPlayer.State.RUNNING) { - activeId = player.nextStep().map(LiveScenarioPlayer.Line::id).orElse(null); - } - String marker = Objects.equals(activeId, line.id()) ? "▶ " : " "; - label.setText(marker + line.text()); - return label; - } - } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java index 29129b7a..cf5a1478 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java @@ -2,32 +2,66 @@ import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class LiveScenarioPlayerTest { @Test - void interactiveBufferStartsWithWorkingSmokeSteps() { - LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + void defaultDemoScenarioIsNonEmptyAndIncludesBrowserInteraction() { + List demo = LiveScenarioPlayer.DEFAULT_DEMO_SCENARIO; + assertFalse(demo.isEmpty()); + assertTrue(demo.stream().anyMatch(line -> line.contains("navigate to: URL.home"))); + assertTrue(demo.stream().anyMatch(line -> line.contains("click the \"Open Forms Playground\" Link"))); + LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); assertEquals(LiveScenarioPlayer.State.STOPPED, player.state()); player.startFromBeginning(); - assertEquals(" Given ---workbench-player-smoke-1", player.nextStep().orElseThrow().text()); + assertEquals(" Given navigate to: URL.home", player.nextStep().orElseThrow().text()); assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); } @Test - void globalRunAlwaysStartsFromBeginning() { + void clickingAStepInstantlyMovesThePlayhead() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( "Given first", "And second", "Then third" )); + long first = player.lines().get(0).id(); + long second = player.lines().get(1).id(); + long third = player.lines().get(2).id(); + + assertEquals(first, player.playheadId().orElseThrow()); + + player.clickLine(third); + assertEquals(third, player.playheadId().orElseThrow()); + assertEquals(third, player.selectedId().orElseThrow()); + + player.clickLine(second); + assertEquals(second, player.playheadId().orElseThrow()); + assertEquals("And second", player.playheadLine().orElseThrow().text()); + } + @Test + void globalPlayAlwaysStartsFromBeginning() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given first", + "And second", + "Then third" + )); + player.clickLine(player.lines().get(2).id()); player.startFromBeginning(); + + assertEquals("Given first", player.nextStep().orElseThrow().text()); + assertEquals(player.lines().getFirst().id(), player.playheadId().orElseThrow()); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); assertEquals("Then third", player.nextStep().orElseThrow().text()); @@ -37,19 +71,22 @@ void globalRunAlwaysStartsFromBeginning() { } @Test - void fromHereStartsAtSelectedStep() { + void stepEditorHasIsolatedAndFromHerePlayActions() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( "Given first", "And second", "Then third" )); long second = player.lines().get(1).id(); - player.select(second); + player.clickLine(second); player.startFromSelectedStep(); - assertEquals("And second", player.nextStep().orElseThrow().text()); assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + + player.pauseForIsolatedExecution(); + assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + assertEquals(second, player.nextStep().orElseThrow().id()); } @Test @@ -58,7 +95,7 @@ void fromHereRequiresExecutableSelection() { "Scenario: sample", "Given first" )); - player.select(player.lines().getFirst().id()); + player.clickLine(player.lines().getFirst().id()); IllegalStateException failure = assertThrows( IllegalStateException.class, @@ -68,35 +105,78 @@ void fromHereRequiresExecutableSelection() { } @Test - void successfulRunWaitsAtEndAndAddedStepContinues() { + void successfulRunWaitsAtEndAndEnterAppendsAndContinues() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); player.startFromBeginning(); player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); + assertTrue(player.nextStep().isEmpty()); + player.clickLine(player.lines().getFirst().id()); LiveScenarioPlayer.Line added = player.insertStep("And added"); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); assertEquals(added.id(), player.nextStep().orElseThrow().id()); + assertEquals(added.id(), player.playheadId().orElseThrow()); + assertEquals("And added", player.lines().get(1).text()); + } + + @Test + void typingAtEndWhileWaitingQueuesTheNewStep() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of("Given first")); + player.startFromBeginning(); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); + + List edited = new ArrayList<>(player.lines().stream().map(LiveScenarioPlayer.Line::text).toList()); + edited.add("Then continue"); + player.replaceDocument(edited); + + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + assertEquals("Then continue", player.nextStep().orElseThrow().text()); } @Test - void enterInsertsAfterSelectionAndStepsRemainEditableAfterExecution() { + void executedLinesRemainEditableInPlace() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( "Given first", "Then third" )); long first = player.lines().getFirst().id(); - player.select(first); + player.clickLine(first); LiveScenarioPlayer.Line inserted = player.insertStep("And second"); assertEquals("And second", player.lines().get(1).text()); - player.select(inserted.id()); - player.startFromSelectedStep(); + player.startFromBeginning(); + player.markCurrentStepExecuted(first); player.markCurrentStepExecuted(inserted.id()); - LiveScenarioPlayer.Line updated = player.updateSelectedStep("And edited second"); - assertEquals("And edited second", updated.text()); + LiveScenarioPlayer.Line updated = player.updateLine(first, "Given edited first"); + assertEquals(first, updated.id()); + assertEquals("Given edited first", player.lines().getFirst().text()); + + player.clickLine(inserted.id()); + LiveScenarioPlayer.Line fromEditor = player.updateSelectedStep("And edited second"); + assertEquals(inserted.id(), fromEditor.id()); + assertEquals("And edited second", player.lines().get(1).text()); + } + + @Test + void replaceDocumentKeepsStableIdsWhenEditingInPlace() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given first", + "Then second" + )); + long first = player.lines().getFirst().id(); + long second = player.lines().get(1).id(); + + player.replaceDocument(List.of("Given edited first", "Then second")); + + assertEquals(first, player.lines().getFirst().id()); + assertEquals(second, player.lines().get(1).id()); + assertEquals("Given edited first", player.lines().getFirst().text()); + assertEquals(LiveScenarioPlayer.LineType.STEP, player.lines().getFirst().type()); } @Test @@ -112,7 +192,9 @@ void failurePausesOnCurrentStep() { assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); assertEquals(first, player.nextStep().orElseThrow().id()); + assertEquals(first, player.playheadId().orElseThrow()); } + @Test void insertingIntoCompletedMiddleDoesNotReplayLaterSteps() { LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( @@ -120,17 +202,18 @@ void insertingIntoCompletedMiddleDoesNotReplayLaterSteps() { "And second", "Then third" )); - long first = player.lines().getFirst().id(); player.startFromBeginning(); while (player.nextStep().isPresent()) { player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); } - player.select(first); + assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); - player.insertStep("And inserted in middle"); + List edited = new ArrayList<>(player.lines().stream().map(LiveScenarioPlayer.Line::text).toList()); + edited.add(1, "And inserted in middle"); + player.replaceDocument(edited); assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); assertTrue(player.nextStep().isEmpty()); + assertNotEquals("And inserted in middle", player.lines().getFirst().text()); } - } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java index 1fb99ba7..ce051af1 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java @@ -68,6 +68,41 @@ void workerLifecycleDelegatesToSharedWorkbenchServices() { ), recording.calls); } + @Test + void isolatedPlayerStepSendsDisplayedGherkinUnchanged() { + RecordingServices recording = new RecordingServices(); + WorkbenchUiController controller = new WorkbenchUiController( + Path.of("consumer"), + recording.services() + ); + + WorkbenchUiController.PlayerStepResult result = controller.executePlayerStep( + " Given navigate to: URL.home" + ); + + assertTrue(result.successful()); + assertTrue(recording.calls.contains("executeStep: Given navigate to: URL.home:")); + } + + @Test + void freshScenarioPlaybackRestartsTheWorkerOnTheSharedServiceSeam() { + RecordingServices recording = new RecordingServices(); + WorkbenchUiController controller = new WorkbenchUiController( + Path.of("consumer"), + recording.services() + ); + + controller.refresh(); + controller.startWorker(); + recording.calls.clear(); + WorkbenchUiController.State fresh = controller.prepareFreshLiveSession(); + + assertTrue(fresh.liveReady()); + assertTrue(recording.calls.contains("synchronizationStatus")); + assertTrue(recording.calls.contains("restartWorker")); + assertFalse(recording.calls.stream().anyMatch(call -> call.startsWith("executeStep"))); + } + @Test void liveGherkinAndMappingDelegateToSharedWorkbenchServicesAndRefreshEvents() { RecordingServices recording = new RecordingServices(); diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/README.md index 106dfc6e..6f1ae4cd 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/README.md @@ -38,6 +38,7 @@ When these docs are materialized from the Maven dependency with `DiagnosticCli e - [Dynamic control API](dynamic-control-api.md) — optional retry-friendly dynamic Gherkin execution, isolated/scoped ParsingMap control, snapshots, value interception, synchronous semantic hooks, and the consumer-side Control Bridge used by Workbench. - [Step Overrides](step-overrides.md) — live REGEX/REPLACE step implementation authoring in a persistent worker, including generated Java handlers and Workbench management. - [Pickleball Workbench](pickleball-workbench.md) — dependency-matched external controller embedded opaquely in Pickleball, with a neutral versioned protocol, strict core-free artifact/process boundary, consumer-classpath worker, MCP stdio, and Swing UI. +- [Workbench live player](pickleball-workbench-player.md) — click-to-seek playhead, Play from start, Step vs From Here, wait-at-end add-and-continue, in-place Gherkin editing, and the default local-site demo. - [Diagnostic lineage and metadata](diagnostic-lineage-metadata.md) — distinguish lineage annotations, execution/evidence RunVars, controls, and derived evidence. - [Diagnostic reporting](diagnostic-reporting.md) — sparse-first AI evidence, source provenance, step/capability metadata, trace evidence, screenshots/fingerprints, comparison, and retention. - [AI diagnostic reporting plan](ai-diagnostic-reporting-plan.md) — current sparse-first investigation and controlled-rerun architecture. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index c5376dae..3ba8c647 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the session buffer, stable line identities, selection, playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. The default buffer is a Workbench-owned browser demo against `URL.home`. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index ebb90e76..9f9807dc 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -47,6 +47,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `docs/key-parser-dsl.md` - `docs/mapping-and-templating.md` - `docs/nested-steps.md` +- `docs/pickleball-workbench-player.md` - `docs/pickleball-workbench.md` - `docs/README.md` - `docs/service-call-scenarios.md` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md new file mode 100644 index 00000000..f7ca0bb2 --- /dev/null +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md @@ -0,0 +1,114 @@ +# Pickleball Workbench Live Player + +This document describes the live-player behavior implemented by the Workbench UI on the 2.1.9 line. The canonical Workbench guide is [pickleball-workbench.md](pickleball-workbench.md). + +## Architecture boundary + +The Workbench distribution and process model is unchanged: + +```text +published pickleball JAR + -> embeds one opaque pickleball-workbench.jar + -> launcher extracts it and starts `java -jar` in a separate controller JVM + +Workbench controller JVM + -> controller/UI/MCP only + -> shares only pickleball-control-protocol wire classes + -> never loads Pickleball core, control API, consumer classes, Cucumber, Selenium, or REST-assured + +consumer worker JVM + -> runs from the synchronized consumer test-runtime classpath + -> owns Pickleball, Cucumber, DynamicControl, Mapping/ParsingMap/NodeMap, browser, and service behavior +``` + +The player and Mapping editor do not change this boundary. Swing is a presentation adapter over `WorkbenchServices` / `WorkbenchController`. Automatic buffered execution and add-and-continue use the existing `executeStep` worker contract; Workbench does not invent a second Gherkin matcher. + +## Live Scenario Editor + +The left-side editor is a session-scoped Gherkin text editor with a player playhead. Clicking a line instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. + +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files. The default demo is a small browser scenario against the Maven consumer local test site: + +```gherkin +Feature: Workbench Live Scenario + +Scenario: Open the local test site + Given navigate to: URL.home + When , ensure "Pickleball Test Lab" Text is displayed + And , click the "Open Forms Playground" Link + Then , ensure "Forms Playground" Text is displayed +``` + +`URL.home` is ordinary consumer config, not a machine-specific filesystem path. Users can edit any line in place, including Gherkin that already executed. Stable line identities are preserved across in-place edits. + +### Controls + +- Clicking a scenario step instantly moves the playhead to that step. +- The global **Play** button always creates a fresh interactive scenario context and runs from the first executable scenario step, not from the current playhead. +- **Pause** prevents the next automatic step from starting. An already in-flight step is allowed to finish. +- **Stop** stops automatic advancement but does not kill the consumer worker. Worker lifecycle remains under **Session**. +- The Step Editor has two execution actions: + - **Step** executes only the Step Editor text against the current paused live context and leaves automatic scenario playback paused. + - **From Here** creates a fresh interactive scenario context and treats the selected/playhead executable step as the first step of that run, then continues through the remaining buffer. +- Fresh scenario playback restarts the consumer worker so browser, Mapping, service, and other side effects from a previous run do not leak into a new **Play** or **From Here** run. +- Reaching the end while playing changes the player to **Waiting for step** rather than stopping. Typing a new step and pressing **Enter** appends it to the end of the live scenario and executes it as part of the same live run. +- **Ctrl+Enter** updates the selected line in place. The whole-scenario editor also accepts ordinary typing at any line. +- The editor highlights the current playhead line. Successful lines do not retain checkmarks or become locked. + +First/Step Back, when present, remain navigation-only and do not rewind runtime side effects. The current player relies on click-to-seek instead of those buttons. + +## Full Gherkin line execution + +The Workbench sends the displayed line unchanged over the existing `execute_step` bridge operation. + +If the input starts with `Given`, `When`, `Then`, `And`, `But`, or `*`, `DynamicControl` parses that one line using Pickleball/Cucumber inside the consumer worker and executes the resulting detached step text. + +The controller does not strip keywords or load a Gherkin parser. + +Historical raw detached-step input remains supported. + +## Mapping tab + +The Mapping tab is an object editor rather than a get/put/resolve form. + +It contains: + +1. A **NodeMap** dropdown populated from the actual NodeMaps in the current worker-side `ParsingMap`. +2. One editable JSON text area containing the materialized root object of the selected NodeMap. + +The dropdown is populated through the existing Mapping snapshot contract using a reserved neutral protocol reference. The worker resolves the reserved reference to a catalog generated from the current `ParsingMap`; the Workbench sees only neutral snapshot data. + +Each catalog entry uses a second reserved reference that resolves back to the same current NodeMap through `MappingControl`. Ordinary NodeMap references continue to behave unchanged. + +### Editing + +For an ordinary restorable NodeMap: + +- change scalar values directly; +- add or delete properties; +- add or edit nested objects; +- add or edit arrays; +- assign an object as a value by entering its JSON object structure. + +After a short debounce, valid JSON is restored through the existing `mapping_restore` bridge operation. Invalid intermediate JSON is not sent to the worker. + +NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only, preserving the existing restore safety rule. + +## Focused validation + +The included consumer `@control-bridge` scenario verifies: + +- a full `Given CONTROL API TEST STEP` line is parsed in the consumer worker and normalized to the existing step text; +- the current ParsingMap catalog contains at least one NodeMap; +- a catalog reference resolves back to a live NodeMap. + +Workbench player/editor unit tests cover click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, and the non-empty browser demo seed. + +Workbench changes should continue to use the repository's focused validation policy: + +```powershell +.\gradlew.bat verifyStrictControllerIsolation :pickleball-workbench:test +.\maven-consumer-project\mvnw.cmd -f maven-consumer-project\pom.xml -U test -Dpkb_runvars.pkb_browser=CHROME_HEADLESS -Dpkb_runvars.pkb_parallel=80 -Dpkb_runvars.pkb_tags=@control-bridge +``` + +Do not use `@all` as Workbench migration validation. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index e0f5d466..d3ba4e3d 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -118,21 +118,20 @@ The Swing UI is a presentation adapter over the same `WorkbenchServices` / `Work The primary workspace is now arranged as an interactive scenario player: ```text -┌─────────────────────────────────────────────────────────────────────┐ -│ Project / readiness ⏮ ◀ ▶ ⏸ ■ Player status │ -├───────────────────────────────┬─────────────────────────────────────┤ -│ LIVE SCENARIO EDITOR │ Mapping | Terminal | Diagnostic Log │ -│ │ │ -│ Feature: ... │ selected right-side workspace │ -│ Scenario: ... │ │ -│ ▶ next playhead step │ │ -│ selected/other line │ │ -├───────────────────────────────┤ │ -│ Step Editor / Command ▶ │ │ -│ [ live command text ] │ │ -└───────────────────────────────┴─────────────────────────────────────┘ -│ Workbench/session activity │ -└─────────────────────────────────────────────────────────────────────┘ ++-------------------------------------------------------------------+ +| Project / readiness Play Pause Stop Player status | ++----------------------------------+--------------------------------+ +| LIVE SCENARIO EDITOR | Mapping | Terminal | Diagnostic| +| (in-place Gherkin text) | | +| Feature: ... | selected right-side workspace | +| Scenario: ... | | +| highlighted playhead step | | ++----------------------------------+ | +| Step Editor [Step] [From Here] | | +| [ live command text ] | | ++----------------------------------+--------------------------------+ +| Workbench/session activity | ++-------------------------------------------------------------------+ ``` The left side contains only the Live Scenario Editor and compact Step Editor / Command. The right side has one tabbed workspace for Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls are available from the **Session** menu. Existing investigation tools are available from **Tools > Advanced Controls** so the underlying capabilities are preserved without dominating the normal workflow. @@ -142,51 +141,67 @@ The left side contains only the Live Scenario Editor and compact Step Editor / C `LiveScenarioPlayer` is a headless Workbench-side presentation model. It owns only: - stable line identities independent of display line number; -- the live session buffer; +- the live session buffer as editable Gherkin text; - selected line; -- playhead insertion point; -- pending/executed/failed presentation status; +- playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -Selection and playhead are independent. Selecting another line does not move the playhead. New Step Editor commands are inserted at the playhead insertion point, not at the text caret or selected line. +The Live Scenario Editor is a regular in-place Gherkin editor. Users can type at any line, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. The buffer is a session document; Workbench does not write it back to consumer `.feature` files. -The initial buffer is an interactive session buffer; it is not automatically written back to consumer `.feature` files. +The playhead behaves like an audio-player needle: -Step Editor gestures are intentionally explicit: +- clicking a scenario line instantly seeks the playhead to that line; +- while a run is active, the playhead follows the current/next executable step after each success; +- **Pause** and **Stop** do not claim to rewind browser, Mapping, service, or other worker side effects. + +Global **Play** always starts a fresh interactive scenario context and runs from the first executable step, even if the playhead is elsewhere. Fresh **Play** / **From Here** runs restart the consumer worker so prior side effects do not masquerade as the start of a scenario. + +### Step Editor play actions + +The Step Editor exposes two distinct play actions on the same `WorkbenchServices.executeStep` seam used by MCP: ```text -Enter insert a new command at the playhead -Ctrl+Enter update the selected pending executable step -▶ execute the Step Editor text in isolation through the existing live service +▶ Step execute only the Step Editor text in the current paused live context +▶ From Here start a fresh scenario context and run from the selected/playhead step through the rest of the buffer +Enter insert a step (append-and-run while waiting at end) +Ctrl+Enter update the selected line in place ``` -Executed or failed buffer steps cannot be edited in place in this phase because the UI must not imply that browser, service, or other external side effects were undone. +**Step** pauses automatic playback, sends the displayed Gherkin unchanged, and leaves the main player paused. **From Here** treats the selected executable step as the first step of a new run. -The **First** and **Step Back** controls are navigation-only in this phase. They do not claim to rewind Pickleball runtime state or undo external side effects. +Workbench never strips `Given` / `When` / `Then` / `And` / `But` / `*` and does not contain a second Gherkin matcher. If a displayed line starts with one of those keywords, worker-side `DynamicControl` parses that one line through `GherkinControl` and executes the resulting detached step. Historical raw detached-step text remains supported. -### Phase 1 execution boundary +### Stay in play / add-and-continue -The current player-style increment establishes the new layout and headless player/buffer state without inventing new Pickleball runtime semantics. +Reaching the end of the buffer does not drop out of play. The player remains `WAITING_FOR_STEP`. Typing a new step in the Step Editor and pressing **Enter** appends that step to the end of the live scenario and queues it through the same `executeStep` contract. Adding an executable line at the end of the in-place editor while waiting does the same. Inserting a line earlier in the document does not replay later steps. -The main Play/Pause/Stop controls currently update the buffer/player state model only. Automatic buffered execution, wait-at-end execution, and add-and-continue behavior are the next implementation phase. That loop must be wired through an explicit Pickleball-owned Gherkin/runtime contract; Swing must not strip `Given`/`When`/`Then`, create a second step matcher, or otherwise guess how displayed Gherkin maps to detached step execution. +### Default demo scenario -The small Step Editor Play button continues to use the existing `WorkbenchServices.executeStep` contract unchanged. It pauses the main player state before isolated execution and does not automatically resume it afterward. +A new Workbench session loads a Workbench-owned sample, not a blank buffer and not a consumer `.feature` file. The default scenario is a small browser demo against the existing Maven consumer local test site: -### Mapping tab +```gherkin +Feature: Workbench Live Scenario -There is no GUI-defined `Current Scope` concept. +Scenario: Open the local test site + Given navigate to: URL.home + When , ensure "Pickleball Test Lab" Text is displayed + And , click the "Open Forms Playground" Link + Then , ensure "Forms Playground" Text is displayed +``` + +`URL.home` comes from the consumer's ordinary config mapping. The sample does not hard-code machine-specific filesystem paths. Once a worker is up, **Play** exercises real browser navigation and a click on the local test site. -The target Mapping design is a single NodeMap selector populated from the real `ParsingMap` associated with the selected step, with common NodeMaps remaining available across the live scenario. The GUI must not hard-code names or recreate inheritance rules. +### Mapping tab -The Phase 1 UI therefore leaves the NodeMap selector unavailable until the required Pickleball-side ParsingMap inspection contract exists. Existing Mapping get/put/resolve controls remain available as compatibility controls. The Swing Mapping put control continues to send entered values as text; MCP continues to support arbitrary JSON-compatible Mapping values through the shared service method. +There is no GUI-defined `Current Scope` concept. The Mapping tab is one NodeMap selector populated from the current worker-side `ParsingMap`, plus one JSON object editor for the selected NodeMap root. Valid JSON edits are restored through the existing `mappingRestore` service. The GUI must not hard-code NodeMap names or recreate inheritance rules. -Structural NodeMap browsing/mutation is a later phase and must be implemented against real worker-side Pickleball state. +NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only. MCP continues to support arbitrary JSON-compatible Mapping values through the shared service methods. ### Terminal and Diagnostic Log Explorer The Terminal tab currently displays Workbench UI activity only. Worker log streaming, level filtering, search, and auto-scroll belong to the Terminal phase and must use the appropriate worker/Workbench logging source without violating the MCP stdout contract. -The Diagnostic Log Explorer tab is intentionally a placeholder in Phase 1. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: +The Diagnostic Log Explorer tab remains a placeholder until it is bound to Pickleball's retained diagnostic artifacts. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: 1. `run-catalog.json` 2. selected `run-index.json` / `clusters.json` @@ -218,7 +233,7 @@ The Step Override editor sends its source template unchanged to the worker. The Synchronization, worker actions, live bridge calls, Mapping operations, event refresh, Step Override actions, browser/screenshot evidence, service calls, and breakpoint actions run off the Swing Event Dispatch Thread. Live controls are enabled only while the Workbench-owned worker is running and paused. -The UI is intentionally not a project IDE, general feature-file editor, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. +The UI is intentionally not a project IDE, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. The Live Scenario Editor is a session-scoped Gherkin player/editor, not a workspace file explorer and not an automatic writer of consumer `.feature` files. ## MCP stdio @@ -336,7 +351,7 @@ io.modelcontextprotocol.sdk:mcp-core:2.0.0 io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0 ``` -## Manual UI acceptance for the Phase 1 player foundation +## Manual UI acceptance for the live player/editor ```powershell $workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar" @@ -346,14 +361,17 @@ java -jar $workbenchJar ui ".\maven-consumer-project" Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. 1. Verify the top-level layout has the Live Scenario Editor and compact Step Editor on the left, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. -2. Insert multiple commands with Enter and verify each is inserted at the visible playhead while selection can remain on another line. -3. Select a pending command, change its text, press Ctrl+Enter, and verify its displayed line updates without changing its stable position semantics. -4. Use First and Step Back and verify the playhead indicator moves independently from the selection. Treat these as navigation-only; no runtime rewind is claimed. -5. Use Play/Pause/Stop and verify player presentation states, including `Waiting for next step...` when Play has no next buffered command. Do not treat this as Phase 2 automatic runtime execution. -6. Open **Session**, synchronize/start a worker, select or enter a valid existing live raw Gherkin command, click the small Step Editor Play button, and verify it delegates isolated execution and leaves the main player paused. -7. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Existing get/put/resolve controls remain usable with a paused worker. -8. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. -9. Verify blocking runtime actions leave the Swing UI responsive. +2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site. +3. Click different scenario steps and verify the playhead highlight moves immediately to the clicked line. +4. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. +5. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. +6. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. +7. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. +8. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. +9. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. +10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. The NodeMap JSON editor remains usable with a paused worker. +11. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +12. Verify blocking runtime actions leave the Swing UI responsive. ## Regression diff --git a/src/main/resources/META-INF/pickleball/guidance/index.txt b/src/main/resources/META-INF/pickleball/guidance/index.txt index c71e7530..574e273e 100644 --- a/src/main/resources/META-INF/pickleball/guidance/index.txt +++ b/src/main/resources/META-INF/pickleball/guidance/index.txt @@ -27,6 +27,7 @@ docs/getting-started.md docs/key-parser-dsl.md docs/mapping-and-templating.md docs/nested-steps.md +docs/pickleball-workbench-player.md docs/pickleball-workbench.md docs/service-call-scenarios.md docs/step-overrides.md From a0b856e318792775738fb21998e8feb8a7b6aec1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 03:25:01 +0000 Subject: [PATCH 06/15] Restore execute bits on POSIX wrappers so CI can run Gradle. gradlew, maven-consumer-project/mvnw, and scripts/agent_validate.sh were committed as 100644. CI now also chmod +x immediately before invoking the wrappers. Co-authored-by: ds-code-t --- .github/workflows/agent-contract.yml | 8 ++++++-- gradlew | 0 maven-consumer-project/mvnw | 0 scripts/agent_validate.sh | 0 4 files changed, 6 insertions(+), 2 deletions(-) mode change 100644 => 100755 gradlew mode change 100644 => 100755 maven-consumer-project/mvnw mode change 100644 => 100755 scripts/agent_validate.sh diff --git a/.github/workflows/agent-contract.yml b/.github/workflows/agent-contract.yml index d733da3c..5513e516 100644 --- a/.github/workflows/agent-contract.yml +++ b/.github/workflows/agent-contract.yml @@ -47,7 +47,11 @@ jobs: run: python scripts/refresh_agent_index.py --check - name: Test and publish framework locally - run: ./gradlew test publishToMavenLocal + run: | + chmod +x gradlew + ./gradlew test publishToMavenLocal - name: Test Maven consumer project - run: bash ./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_browser=CHROME_HEADLESS + run: | + chmod +x maven-consumer-project/mvnw + bash ./maven-consumer-project/mvnw -f maven-consumer-project/pom.xml -U test -Dpkb_browser=CHROME_HEADLESS diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/maven-consumer-project/mvnw b/maven-consumer-project/mvnw old mode 100644 new mode 100755 diff --git a/scripts/agent_validate.sh b/scripts/agent_validate.sh old mode 100644 new mode 100755 From aad9b763cab47a998ed24fa904275f6b3302d285 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 08:00:43 +0000 Subject: [PATCH 07/15] Add Workbench Gherkin block editor, picker, and JavaFX WebView panels. Turn the 2.1.9 Workbench player into a feature/scenario picker plus live block editor while keeping Play/Step/From Here on LiveScenarioPlayer and WorkbenchServices. Mapping, terminal, and diagnostic explorer stay on existing worker and retained-run evidence. OpenJFX WebView is Workbench-only; the launcher now streams the larger controller payload. Co-authored-by: ds-code-t --- build.gradle | 28 +- docs/agent/feature-map.md | 4 +- docs/agent/repository-index.md | 32 ++ docs/pickleball-workbench-player.md | 39 +- docs/pickleball-workbench.md | 69 +-- pickleball-workbench/AGENTS.md | 13 +- .../WORKBENCH-PLAYER-CONTEXT.md | 4 +- pickleball-workbench/build.gradle | 84 +++- .../dscode/workbench/WorkbenchController.java | 12 + .../dscode/workbench/WorkbenchServices.java | 7 + .../catalog/ConsumerFeatureCatalog.java | 388 ++++++++++++++++ .../DiagnosticEvidenceNavigator.java | 295 ++++++++++++ .../workbench/mapping/MappingTreeModel.java | 143 ++++++ .../workbench/mapping/MappingValueCodec.java | 173 ++++++++ .../player/GherkinBlockDocument.java | 261 +++++++++++ .../player/LivePlaybackCoordinator.java | 89 ++++ .../workbench/player/LiveScenarioPlayer.java | 21 + .../workbench/terminal/WorkerLogBuffer.java | 118 +++++ .../workbench/terminal/WorkerLogFiles.java | 15 + .../workbench/ui/FeaturePickerPanel.java | 229 ++++++++++ .../dscode/workbench/ui/TerminalPanel.java | 126 ++++++ .../dscode/workbench/ui/WorkbenchFrame.java | 420 +++++++++++++++--- .../dscode/workbench/ui/WorkbenchTheme.java | 134 ++++++ .../dscode/workbench/ui/WorkbenchUi.java | 1 + .../workbench/ui/WorkbenchUiController.java | 21 +- .../ui/web/DiagnosticExplorerHost.java | 40 ++ .../workbench/ui/web/GherkinEditorHost.java | 61 +++ .../workbench/ui/web/JavaFxSupport.java | 119 +++++ .../workbench/ui/web/MappingEditorHost.java | 56 +++ .../dscode/workbench/ui/web/WebViewPanel.java | 101 +++++ .../workbench/ui/web/WorkbenchWebJson.java | 102 +++++ .../worker/WorkbenchLiveSession.java | 6 + .../worker/WorkbenchWorkerManager.java | 8 + .../workbench/ui/web/diagnostic-explorer.css | 60 +++ .../workbench/ui/web/diagnostic-explorer.html | 29 ++ .../workbench/ui/web/diagnostic-explorer.js | 81 ++++ .../workbench/ui/web/gherkin-editor.css | 92 ++++ .../workbench/ui/web/gherkin-editor.html | 16 + .../dscode/workbench/ui/web/gherkin-editor.js | 228 ++++++++++ .../workbench/ui/web/mapping-editor.css | 36 ++ .../workbench/ui/web/mapping-editor.html | 19 + .../dscode/workbench/ui/web/mapping-editor.js | 106 +++++ .../catalog/ConsumerFeatureCatalogTest.java | 105 +++++ .../DiagnosticEvidenceNavigatorTest.java | 51 +++ .../mapping/MappingValueCodecTest.java | 57 +++ .../player/GherkinBlockDocumentTest.java | 70 +++ .../player/LivePlaybackCoordinatorTest.java | 66 +++ .../player/LiveScenarioPlayerTest.java | 10 + .../terminal/WorkerLogBufferTest.java | 30 ++ .../ui/WorkbenchUiControllerTest.java | 41 ++ .../launcher/PickleballWorkbenchLauncher.java | 103 +++-- .../guidance/docs/agent/feature-map.md | 4 +- .../guidance/docs/agent/repository-index.md | 32 ++ .../docs/pickleball-workbench-player.md | 39 +- .../guidance/docs/pickleball-workbench.md | 69 +-- .../PickleballWorkbenchLauncherTest.java | 9 + 56 files changed, 4384 insertions(+), 188 deletions(-) create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/MappingEditorHost.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WebViewPanel.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.css create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.html create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html create mode 100644 pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java diff --git a/build.gradle b/build.gradle index 49e32624..5750b2e0 100644 --- a/build.gradle +++ b/build.gradle @@ -342,7 +342,15 @@ tasks.register('verifyEmbeddedWorkbench') { doLast { File outerFile = tasks.named('shadowJar').get().archiveFile.get().asFile File standaloneFile = standaloneWorkbenchJar.get().asFile - byte[] standaloneBytes = standaloneFile.bytes + def sha256 = { InputStream input -> + def digest = java.security.MessageDigest.getInstance('SHA-256') + byte[] buf = new byte[65536] + int read + while ((read = input.read(buf)) >= 0) { + digest.update(buf, 0, read) + } + digest.digest() + } new java.util.jar.JarFile(outerFile).withCloseable { outer -> def payloadEntries = java.util.Collections.list(outer.entries()).findAll { entry -> @@ -355,8 +363,15 @@ tasks.register('verifyEmbeddedWorkbench') { ) } - byte[] embeddedBytes = outer.getInputStream(payloadEntries.first()).readAllBytes() - if (!java.util.Arrays.equals(standaloneBytes, embeddedBytes)) { + byte[] standaloneHash + standaloneFile.withInputStream { input -> + standaloneHash = sha256(input) + } + byte[] embeddedHash + outer.getInputStream(payloadEntries.first()).withCloseable { input -> + embeddedHash = sha256(input) + } + if (!java.util.Arrays.equals(standaloneHash, embeddedHash)) { throw new GradleException( 'Embedded Workbench payload bytes do not match the standalone controller build.' ) @@ -373,10 +388,9 @@ tasks.register('verifyEmbeddedWorkbench') { ) } - new java.util.jar.JarInputStream(new ByteArrayInputStream(embeddedBytes)).withCloseable { nested -> - def manifest = nested.manifest - if (manifest?.mainAttributes?.getValue('Main-Class') - != 'tools.dscode.workbench.WorkbenchApplication') { + new java.util.jar.JarFile(standaloneFile).withCloseable { nested -> + def mainClass = nested.manifest?.mainAttributes?.getValue('Main-Class') + if (mainClass != 'tools.dscode.workbench.WorkbenchApplication') { throw new GradleException('Embedded Workbench payload has an unexpected Main-Class.') } } diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 3ba8c647..0adb407d 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. The default buffer is a Workbench-owned browser demo against `URL.home`. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 9f9807dc..1a082cd2 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -476,13 +476,30 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/AGENTS.md` - `pickleball-workbench/build.gradle` - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/MappingEditorHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WebViewPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java` @@ -492,11 +509,26 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerStatus.java` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js` - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md index f7ca0bb2..1b9c6763 100644 --- a/docs/pickleball-workbench-player.md +++ b/docs/pickleball-workbench-player.md @@ -21,13 +21,21 @@ consumer worker JVM -> owns Pickleball, Cucumber, DynamicControl, Mapping/ParsingMap/NodeMap, browser, and service behavior ``` -The player and Mapping editor do not change this boundary. Swing is a presentation adapter over `WorkbenchServices` / `WorkbenchController`. Automatic buffered execution and add-and-continue use the existing `executeStep` worker contract; Workbench does not invent a second Gherkin matcher. +The player, picker, Mapping editor, Terminal, and Diagnostic explorer do not change this boundary. Swing and WebView are presentation adapters over `WorkbenchServices` / `WorkbenchController`. Automatic buffered execution and add-and-continue use the existing `executeStep` worker contract; Workbench does not invent a second Gherkin matcher. WebView JavaScript never executes Gherkin. + +## Feature and scenario picker + +The expandable left rail lists `.feature` files from the synchronized consumer project: manifest source roots, conventional `src/test/resources/features`, live merged `features/` resources, and an explicit project-owned `pkb_features` value when present. It does not crawl an unrelated git worktree. + +Toggle browse mode between Gherkin Feature name and file name + directory path. Click selects or deselects features. The scenario list then shows scenarios under the selection. With no feature selected, the search bar filters every scenario in that project catalog. Clicking a scenario loads it into the live editor. The default demo remains loaded until a scenario is chosen. **Save** is the only write-back path. ## Live Scenario Editor -The left-side editor is a session-scoped Gherkin text editor with a player playhead. Clicking a line instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. +The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Blocks are Gherkin text, including `Given` / `When` / `Then`. Nested steps and `IF` / `ELSE` snap as parent/child using leading colons. Clicking a block instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. + +Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. -The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files. The default demo is a small browser scenario against the Maven consumer local test site: +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario. The default demo is a small browser scenario against the Maven consumer local test site: ```gherkin Feature: Workbench Live Scenario @@ -69,12 +77,12 @@ Historical raw detached-step input remains supported. ## Mapping tab -The Mapping tab is an object editor rather than a get/put/resolve form. +The Mapping tab is a structured object/property editor rather than a get/put/resolve form. It contains: 1. A **NodeMap** dropdown populated from the actual NodeMaps in the current worker-side `ParsingMap`. -2. One editable JSON text area containing the materialized root object of the selected NodeMap. +2. A property tree for that NodeMap. Each row edits key, value text, and type (`string`, `numeric`, `boolean`, `object-as-JSON`, `object-as-XML`). The dropdown is populated through the existing Mapping snapshot contract using a reserved neutral protocol reference. The worker resolves the reserved reference to a catalog generated from the current `ParsingMap`; the Workbench sees only neutral snapshot data. @@ -84,15 +92,20 @@ Each catalog entry uses a second reserved reference that resolves back to the sa For an ordinary restorable NodeMap: -- change scalar values directly; -- add or delete properties; -- add or edit nested objects; -- add or edit arrays; -- assign an object as a value by entering its JSON object structure. +- change scalar values in place and choose their type; +- add or rename properties; +- assign JSON or XML object text, which is decoded and sent as a structured `mappingPut` value; +- rename keys through `mappingRestore` of the current object. + +Invalid typed text is not sent to the worker. NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only, preserving the existing restore safety rule. + +## Terminal + +The Terminal tails the worker stdout/stderr files Workbench already creates under `.pickleball/workbench/logs/`. Filter by `TRACE`, `DEBUG`, `INFO`, `WARNING`, or `ERROR`. Logs continue as the playhead moves. This is not MCP stdout and is not a fabricated Workbench-only activity dump. Unmarked worker output is shown at `INFO`. -After a short debounce, valid JSON is restored through the existing `mapping_restore` bridge operation. Invalid intermediate JSON is not sent to the worker. +## Diagnostic Log Explorer -NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only, preserving the existing restore safety rule. +The explorer is a rewind/play/focus timeline of retained Pickleball diagnostic runs. Screenshot frames are shown with the Gherkin step that was running when they were taken. Denser layers follow the repository evidence order and only open when the retained files exist. If `reports/diagnostic-runs/run-catalog.json` is missing, the panel stays empty and says so. ## Focused validation @@ -102,7 +115,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, and the non-empty browser demo seed. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, and the non-empty browser demo seed. Workbench changes should continue to use the repository's focused validation policy: diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index d3ba4e3d..5b8fa58d 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -70,7 +70,7 @@ tasks.register('pickleballWorkbench', JavaExec) { Run it with `./gradlew pickleballWorkbench` (or `gradlew.bat pickleballWorkbench`). The task uses the consumer's resolved test runtime only to locate the tiny launcher and nested bytes; actual controller code still starts in a separate `java -jar` process. -The launcher reads the nested payload, limits its size, calculates SHA-256, and extracts it atomically to: +The launcher streams the nested payload (it does not load the controller JAR into a byte array), hashes SHA-256 while copying, and rejects payloads larger than 512 MiB. OpenJFX WebView natives make the executable larger than a plain Java controller. It extracts atomically to: ```text .pickleball/workbench/controller//pickleball-workbench.jar @@ -115,26 +115,29 @@ The Swing UI is a presentation adapter over the same `WorkbenchServices` / `Work ### Player-style layout -The primary workspace is now arranged as an interactive scenario player: +The primary workspace is an interactive Gherkin player with a project feature picker: ```text -+-------------------------------------------------------------------+ -| Project / readiness Play Pause Stop Player status | -+----------------------------------+--------------------------------+ -| LIVE SCENARIO EDITOR | Mapping | Terminal | Diagnostic| -| (in-place Gherkin text) | | -| Feature: ... | selected right-side workspace | -| Scenario: ... | | -| highlighted playhead step | | -+----------------------------------+ | -| Step Editor [Step] [From Here] | | -| [ live command text ] | | -+----------------------------------+--------------------------------+ -| Workbench/session activity | -+-------------------------------------------------------------------+ ++-------------------------------------------------------------------------+ +| Features | Project / readiness Play Pause Stop Player status | ++----------+---------------------------+----------------------------------+ +| Feature /| LIVE GHERKIN EDITOR | Mapping | Terminal | Diagnostic | +| file | (WebView block editor) | WebView tree / typed values | +| picker | highlighted playhead | worker log / retained-run frames | +| + search | | | ++----------+---------------------------+ | +| | Step Editor [Step] [From | | +| | Here] [ live command ] | | ++----------+---------------------------+----------------------------------+ +| Footer activity | ++-------------------------------------------------------------------------+ ``` -The left side contains only the Live Scenario Editor and compact Step Editor / Command. The right side has one tabbed workspace for Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls are available from the **Session** menu. Existing investigation tools are available from **Tools > Advanced Controls** so the underlying capabilities are preserved without dominating the normal workflow. +The expandable left rail lists feature files from the synchronized consumer project. Browse by Gherkin Feature name or by file name + directory path. Click toggles feature selection. The scenario list is limited to selected features, or to every project scenario when nothing is selected; the search bar filters that list. Clicking a scenario loads it into the live session buffer. Workbench does not write `.feature` files unless you use the explicit **Save** control. + +The center editor is an embedded HTML/JS block editor hosted in JavaFX `WebView` (`JFXPanel`). Blocks are Gherkin text — including `Given` / `When` / `Then` — not a second language compiled to Gherkin. Nested steps and `IF` / `ELSE` blocks snap as parent/child using Pickleball's leading-colon grammar. The play header is unchanged: click-to-seek, global **Play** from the first step in a fresh worker context, **Step** = isolated `executeStep`, **From Here** = selected/playhead through the rest, stay in play at end, Enter append-and-run. JavaScript never executes Gherkin. + +The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls stay under **Session**. Existing investigation tools stay under **Tools > Advanced Controls**. ### Live scenario buffer and player state @@ -146,7 +149,7 @@ The left side contains only the Live Scenario Editor and compact Step Editor / C - playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -The Live Scenario Editor is a regular in-place Gherkin editor. Users can type at any line, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. The buffer is a session document; Workbench does not write it back to consumer `.feature` files. +The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live; **Save** writes back to the originating `.feature` file when one was loaded. The Workbench-owned demo has no save path. The playhead behaves like an audio-player needle: @@ -193,25 +196,29 @@ Scenario: Open the local test site ### Mapping tab -There is no GUI-defined `Current Scope` concept. The Mapping tab is one NodeMap selector populated from the current worker-side `ParsingMap`, plus one JSON object editor for the selected NodeMap root. Valid JSON edits are restored through the existing `mappingRestore` service. The GUI must not hard-code NodeMap names or recreate inheritance rules. +There is no GUI-defined `Current Scope` concept and no hard-coded NodeMap names. The Mapping tab is a structured property tree populated from the actual NodeMaps in the current worker-side `ParsingMap`. Each property is edited in place: key, value text, and a type dropdown (`string`, `numeric`, `boolean`, `object-as-JSON`, `object-as-XML`). Typed writes go through the existing `mappingPut` service; key renames and whole-object replacement use `mappingRestore`. The GUI must not recreate inheritance rules or keep a second Mapping store. NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only. MCP continues to support arbitrary JSON-compatible Mapping values through the shared service methods. +### WebView packaging + +JDK 21 does not ship a modern browser panel. Workbench embeds OpenJFX `WebView` through `JFXPanel` for the Gherkin editor, Mapping tree, and Diagnostic explorer. That choice stays Workbench-only: Maven-central JavaFX modules are shaded into the controller executable, including platform natives under `javafx-natives//`. JCEF was not used because Chromium natives are harder to keep isolation-clean and do not package as ordinary Workbench dependencies. If JavaFX cannot start, the live editor falls back to the existing in-place text buffer on the same `LiveScenarioPlayer` model. + ### Terminal and Diagnostic Log Explorer -The Terminal tab currently displays Workbench UI activity only. Worker log streaming, level filtering, search, and auto-scroll belong to the Terminal phase and must use the appropriate worker/Workbench logging source without violating the MCP stdout contract. +The Terminal tab presents the existing consumer-worker stdout/stderr capture files under `.pickleball/workbench/logs/` as a scenario-run log. A dropdown filters `TRACE`, `DEBUG`, `INFO`, `WARNING`, and `ERROR`. Lines continue as the playhead moves because the panel tails those files and also records `executeStep` / Mapping results. Workbench does not redirect MCP stdout. If a worker log line has no printed level, it is shown at `INFO` rather than invented. Structured Pickleball logger output is used when present; there is no second log fabricator. -The Diagnostic Log Explorer tab remains a placeholder until it is bound to Pickleball's retained diagnostic artifacts. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: +The Diagnostic Log Explorer is a WebView timeline over Pickleball's retained diagnostic artifacts (`reports/diagnostic-runs`). It follows the existing evidence escalation order and does not create a competing store or fake retained-run data: 1. `run-catalog.json` 2. selected `run-index.json` / `clusters.json` 3. scenario `summary.json` 4. relevant `events.jsonl` 5. existing comparison/fingerprint metadata -6. PNG only when visual content must be inspected +6. PNG frames only when a retained screenshot exists, shown with the Gherkin step text that was running when taken 7. raw trace only when structured evidence is insufficient -The Swing UI must not create a competing diagnostic storage format or fake retained-run data. +If the consumer project has no `run-catalog.json`, the explorer says so and stays empty. ### Existing advanced capabilities @@ -334,7 +341,7 @@ The build proves the controller boundary with `verifyStrictControllerIsolation` - top-level and nested Workbench entries and service descriptors contain no core/worker implementation or nested Pickleball runtime; - the published Workbench POM has no dependencies; - the MCP convenience artifact / Jackson 3 path is not used; -- the outer Pickleball JAR contains exactly one opaque Workbench payload whose bytes equal the standalone output; +- the outer Pickleball JAR contains exactly one opaque Workbench payload whose SHA-256 matches the standalone controller JAR; - Workbench/MCP entries are not flattened into the outer runtime namespace; and - the nested payload has the expected Workbench `Main-Class`. @@ -360,18 +367,20 @@ java -jar $workbenchJar ui ".\maven-consumer-project" Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. -1. Verify the top-level layout has the Live Scenario Editor and compact Step Editor on the left, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. -2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site. -3. Click different scenario steps and verify the playhead highlight moves immediately to the clicked line. +1. Verify the top-level layout has an expandable Features picker, the Live Gherkin Editor and compact Step Editor in the center, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. +2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site when no picker scenario is selected. +3. Click different scenario blocks and verify the playhead highlight moves immediately to the clicked step. 4. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. 5. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. 6. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. 7. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. 8. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. 9. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. -10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. The NodeMap JSON editor remains usable with a paused worker. -11. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. -12. Verify blocking runtime actions leave the Swing UI responsive. +10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Top-level properties come from the worker ParsingMap and accept typed in-place edits. +11. Verify Terminal filters worker log files by level and continues as steps run, without writing to MCP stdout. +12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. +13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +14. Verify blocking runtime actions leave the Swing UI responsive. ## Regression diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index db357b44..76fc81a4 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -66,15 +66,16 @@ java -jar pickleball-workbench-.jar ui The UI is player-style and execution-oriented. Its primary layout is: ```text -left: Live Scenario Editor + compact Step Editor / Command -right: Mapping | Terminal | Diagnostic Log Explorer +left rail: project feature / scenario picker +center: Live Gherkin block editor (JavaFX WebView) + compact Step Editor / Command +right: Mapping | Terminal | Diagnostic Log Explorer ``` Low-level lifecycle controls live under the Session menu and existing investigation controls remain available under Advanced Controls rather than dominating the permanent workspace. `LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, the editable Gherkin document, selected line, playhead, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. -The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document; users may edit any line, including previously executed text. The default buffer is a Workbench-owned browser demo against `URL.home` and is not written back to consumer `.feature` files. +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless the user uses explicit **Save**. WebView JavaScript must not execute Gherkin. The Step Editor has two play actions: **Step** executes only the editor text through `WorkbenchServices.executeStep` and leaves automatic playback paused; **From Here** restarts into a fresh scenario context and runs from the selected/playhead step through the rest of the buffer. Enter while waiting at end appends the step and continues the live run. Do not strip Gherkin keywords or add a Swing-side step matcher. Worker-side `DynamicControl` / `GherkinControl` remain the only Gherkin interpreters. @@ -82,9 +83,11 @@ The Step Editor has two play actions: **Step** executes only the editor text thr Buffered Play / From Here / add-and-continue now execute through the existing live `executeStep` contract. Swing remains a presentation adapter: it sends displayed Gherkin unchanged and never owns a second worker manager, Mapping implementation, or Pickleball runtime. -The Mapping tab must not hard-code NodeMap names. It is one current-ParsingMap NodeMap selector plus a JSON object editor restored through `mappingRestore`. Do not create a fake ParsingMap in Swing. +The Mapping tab must not hard-code NodeMap names. It is one current-ParsingMap NodeMap selector plus a structured property tree. Typed edits go through `mappingPut`; renames/object replacement use `mappingRestore`. Do not create a fake ParsingMap in Swing or WebView. -The Terminal tab currently shows Workbench UI activity only. Actual worker log streaming/filtering is a separate phase and must not be implemented by redirecting MCP stdout. The Diagnostic Log Explorer is also a placeholder until it is bound to Pickleball's existing retained diagnostic artifacts and evidence-escalation model. Do not populate either tab with fake production data. +The Terminal tab tails the existing worker stdout/stderr files and filters TRACE–ERROR. Do not implement it by redirecting MCP stdout or inventing log lines. The Diagnostic Log Explorer binds to Pickleball's retained diagnostic artifacts and evidence-escalation model. Do not populate either tab with fake production data. + +Heavy panels use Workbench-only OpenJFX `WebView` (`JFXPanel`). That choice is documented in `docs/pickleball-workbench.md`. Do not add JCEF or Pickleball-core UI dependencies. Existing capabilities remain available: project/synchronization status, worker lifecycle, live raw Gherkin, Mapping get/put/resolve, semantic events, Step Override list/compile/remove/clear, browser page/screenshot evidence, service-call evidence, and semantic breakpoint list/add/remove/clear. diff --git a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md index 69450f3b..309dc95b 100644 --- a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md +++ b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md @@ -10,7 +10,7 @@ This file records the live-player behavior added on top of those unchanged bound The playhead is the user-visible needle. Clicking a scenario line instantly seeks it, like clicking a waveform. Global Play ignores the playhead and always starts from the first executable buffer step in a fresh interactive worker context. -The Live Scenario Editor is an in-place Gherkin document. Users can type at any line, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files. +The Live Scenario Editor is an in-place Gherkin document shown as WebView blocks whose text is Gherkin. Users can type at any block, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files unless the user uses explicit Save. The left rail picks features/scenarios from the synchronized consumer project. The Step Editor exposes two distinct execution actions: @@ -32,7 +32,7 @@ The Workbench sends displayed Gherkin unchanged. Full `Given`/`When`/`Then`/`And The primary Mapping tab has no get/put/resolve form. It is: - one current-ParsingMap NodeMap dropdown; -- one JSON object editor for the selected NodeMap root. +- a typed property tree (string / numeric / boolean / object-as-JSON / object-as-XML) persisted through `mappingPut` and `mappingRestore`. Current NodeMaps are discovered worker-side through `MappingControl` using the reserved neutral references in `ControlProtocol`. Workbench sees only `ControlBridgeMappingSnapshot` data and must never import `ParsingMap`, `NodeMap`, or other Pickleball runtime classes. diff --git a/pickleball-workbench/build.gradle b/pickleball-workbench/build.gradle index af1aaff3..ba9f7eff 100644 --- a/pickleball-workbench/build.gradle +++ b/pickleball-workbench/build.gradle @@ -20,12 +20,39 @@ java { } def mcpSdkVersion = '2.0.0' +def javafxVersion = '21.0.6' +def javafxModules = ['base', 'graphics', 'controls', 'swing', 'media', 'web'] +def javafxPlatforms = ['linux', 'win', 'mac', 'mac-aarch64'] +def javafxCompilePlatform = { + def os = org.gradle.internal.os.OperatingSystem.current() + def arch = System.getProperty('os.arch', '') + boolean arm = arch.contains('aarch64') || arch.contains('arm64') + if (os.windows) return 'win' + if (os.macOsX) return arm ? 'mac-aarch64' : 'mac' + return 'linux' +}() + +configurations { + javafxNatives +} dependencies { // Workbench shares only the JDK-only wire contract with the consumer worker. // It must never compile or run against the root Pickleball runtime. implementation project(':pickleball-control-protocol') + // OpenJFX WebView is Workbench-only presentation. It is not a Pickleball + // runtime dependency and is preferred over JCEF so natives stay in Maven + // artifacts that can be shaded into the controller executable. + javafxModules.each { module -> + implementation "org.openjfx:javafx-${module}:${javafxVersion}:${javafxCompilePlatform}" + } + javafxPlatforms.each { platform -> + javafxModules.each { module -> + javafxNatives "org.openjfx:javafx-${module}:${javafxVersion}:${platform}" + } + } + // Controller-side JSON transport. This is declared directly instead of // inheriting it through Pickleball core. implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.0' @@ -60,13 +87,57 @@ tasks.jar { enabled = false } +def javafxNativeStage = tasks.register('stageJavaFxNatives') { + def outputDir = layout.buildDirectory.dir('javafx-natives') + outputs.dir(outputDir) + doLast { + def dest = outputDir.get().asFile + dest.deleteDir() + dest.mkdirs() + configurations.javafxNatives.resolvedConfiguration.resolvedArtifacts.each { artifact -> + String classifier = artifact.classifier ?: 'unknown' + def target = new File(dest, classifier) + target.mkdirs() + copy { + from zipTree(artifact.file) + into target + include '**/*.so' + include '**/*.dll' + include '**/*.dylib' + include '**/*.jnilib' + includeEmptyDirs = false + eachFile { details -> + details.path = details.name + } + } + new File(target, '.keep').text = '' + } + } +} + +tasks.named('processResources') { + dependsOn javafxNativeStage + from(layout.buildDirectory.dir('javafx-natives')) { + into 'javafx-natives' + } +} + tasks.shadowJar { + dependsOn javafxNativeStage archiveBaseName.set('pickleball-workbench') archiveVersion.set(project.version.toString()) archiveClassifier.set('') duplicatesStrategy = DuplicatesStrategy.EXCLUDE mergeServiceFiles() zip64 = true + exclude 'module-info.class' + exclude { details -> + String path = details.path ?: '' + String name = details.name ?: '' + boolean nativeLib = name.endsWith('.so') || name.endsWith('.dll') || + name.endsWith('.dylib') || name.endsWith('.jnilib') + nativeLib && !path.startsWith('javafx-natives/') + } manifest { attributes( @@ -216,8 +287,8 @@ tasks.register('verifyWorkbenchArtifact') { def forbidden = [] def scanNestedJar - scanNestedJar = { byte[] bytes, String source -> - new java.util.zip.ZipInputStream(new ByteArrayInputStream(bytes)).withCloseable { nested -> + scanNestedJar = { InputStream input, String source -> + new java.util.zip.ZipInputStream(input).withCloseable { nested -> java.util.zip.ZipEntry entry while ((entry = nested.nextEntry) != null) { String name = entry.name @@ -225,7 +296,10 @@ tasks.register('verifyWorkbenchArtifact') { forbidden.add("${source}!/${name}") } if (!entry.directory && name.endsWith('.jar')) { - scanNestedJar(nested.readAllBytes(), "${source}!/${name}") + scanNestedJar( + new ByteArrayInputStream(nested.readAllBytes()), + "${source}!/${name}" + ) } else if (!entry.directory && name.startsWith('META-INF/services/')) { String providers = new String( nested.readAllBytes(), @@ -276,7 +350,9 @@ tasks.register('verifyWorkbenchArtifact') { forbidden.add(name) } if (!entry.directory && name.endsWith('.jar')) { - scanNestedJar(jar.getInputStream(entry).readAllBytes(), name) + jar.getInputStream(entry).withCloseable { stream -> + scanNestedJar(stream, name) + } } else if (!entry.directory && name.startsWith('META-INF/services/')) { String providers = jar.getInputStream(entry).getText('UTF-8') if (providers.readLines().collect { line -> diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index 5edff3d8..f2d7b6a8 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -3,11 +3,13 @@ import tools.dscode.control.protocol.*; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; +import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchLiveSession; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; import java.nio.file.Path; import java.util.List; +import java.util.Optional; /** Plain-Java controller shared by Workbench adapters. */ public final class WorkbenchController implements WorkbenchServices { @@ -54,6 +56,16 @@ public WorkbenchWorkerStatus workerStatus() { return live.status(); } + @Override + public Path projectRoot() { + return projectRoot; + } + + @Override + public Optional workerLogFiles() { + return live.workerLogFiles(); + } + @Override public ControlBridgeCallResult executeStep(String text, String argument) { return live.executeStep(text, argument == null ? "" : argument); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java index 9aa44874..fabd19cb 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -13,9 +13,12 @@ import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; +import java.nio.file.Path; import java.util.List; +import java.util.Optional; /** Shared Workbench controller surface used by protocol and presentation adapters. */ public interface WorkbenchServices extends AutoCloseable { @@ -31,6 +34,10 @@ public interface WorkbenchServices extends AutoCloseable { WorkbenchWorkerStatus workerStatus(); + Path projectRoot(); + + Optional workerLogFiles(); + ControlBridgeCallResult executeStep(String text, String argument); ControlBridgeValueResult mappingGet(String mapReference, String key); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java new file mode 100644 index 00000000..c2621873 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java @@ -0,0 +1,388 @@ +package tools.dscode.workbench.catalog; + +import tools.dscode.workbench.sync.WorkbenchManifest; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +/** + * Project-owned feature/scenario index for the Workbench picker. + * + *

Discovery stays inside the synchronized consumer project: manifest source + * roots, the merged live resource output, conventional Maven/Gradle test + * resource {@code features} folders, and an explicit {@code pkb_features} + * value when it is already present in project-owned configuration. It does + * not crawl a git worktree or invent a second project model.

+ * + *

Feature/Scenario titles are read as Gherkin structure labels for browsing + * only. This class does not match or execute steps.

+ */ +public final class ConsumerFeatureCatalog { + public enum BrowseMode { + FEATURE_NAME, + FILE_PATH + } + + public record FeatureEntry( + Path file, + String relativePath, + String featureName, + String directoryPath, + String fileName, + List scenarios + ) { + public FeatureEntry { + Objects.requireNonNull(file, "file"); + relativePath = relativePath == null ? "" : relativePath; + featureName = featureName == null || featureName.isBlank() ? fileName : featureName; + directoryPath = directoryPath == null ? "" : directoryPath; + fileName = fileName == null ? file.getFileName().toString() : fileName; + scenarios = List.copyOf(scenarios == null ? List.of() : scenarios); + } + + public String browseLabel(BrowseMode mode) { + if (mode == BrowseMode.FILE_PATH) { + return directoryPath.isBlank() ? fileName : directoryPath + "/" + fileName; + } + return featureName; + } + } + + public record ScenarioEntry( + String name, + String featureName, + Path file, + String relativePath, + int startLine, + int endLine, + List lines + ) { + public ScenarioEntry { + name = name == null ? "" : name; + featureName = featureName == null ? "" : featureName; + Objects.requireNonNull(file, "file"); + relativePath = relativePath == null ? "" : relativePath; + lines = List.copyOf(lines == null ? List.of() : lines); + } + + public String displayLabel() { + return name.isBlank() ? "(unnamed scenario)" : name; + } + } + + private final Path projectRoot; + private final List features; + private final Set selectedFeatures = new LinkedHashSet<>(); + private BrowseMode browseMode = BrowseMode.FEATURE_NAME; + private String scenarioQuery = ""; + + public ConsumerFeatureCatalog(Path projectRoot, WorkbenchManifest manifest) { + this(projectRoot, discover(projectRoot, manifest)); + } + + ConsumerFeatureCatalog(Path projectRoot, List features) { + this.projectRoot = projectRoot.toAbsolutePath().normalize(); + this.features = List.copyOf(features); + } + + public static ConsumerFeatureCatalog scan(Path projectRoot, WorkbenchManifest manifest) { + return new ConsumerFeatureCatalog(projectRoot, manifest); + } + + public Path projectRoot() { + return projectRoot; + } + + public BrowseMode browseMode() { + return browseMode; + } + + public void setBrowseMode(BrowseMode browseMode) { + this.browseMode = browseMode == null ? BrowseMode.FEATURE_NAME : browseMode; + } + + public String scenarioQuery() { + return scenarioQuery; + } + + public void setScenarioQuery(String scenarioQuery) { + this.scenarioQuery = scenarioQuery == null ? "" : scenarioQuery; + } + + public List features() { + return features; + } + + public List featuresForBrowse() { + List copy = new ArrayList<>(features); + if (browseMode == BrowseMode.FILE_PATH) { + copy.sort(Comparator + .comparing(FeatureEntry::directoryPath, String.CASE_INSENSITIVE_ORDER) + .thenComparing(FeatureEntry::fileName, String.CASE_INSENSITIVE_ORDER)); + } else { + copy.sort(Comparator.comparing(FeatureEntry::featureName, String.CASE_INSENSITIVE_ORDER)); + } + return List.copyOf(copy); + } + + public boolean selected(Path file) { + return selectedFeatures.contains(file.toAbsolutePath().normalize()); + } + + public void toggleFeature(Path file) { + Path key = file.toAbsolutePath().normalize(); + if (!selectedFeatures.add(key)) { + selectedFeatures.remove(key); + } + } + + public void selectFeature(Path file) { + selectedFeatures.add(file.toAbsolutePath().normalize()); + } + + public void deselectFeature(Path file) { + selectedFeatures.remove(file.toAbsolutePath().normalize()); + } + + public void clearFeatureSelection() { + selectedFeatures.clear(); + } + + public List selectedFeatureFiles() { + return List.copyOf(selectedFeatures); + } + + /** + * Scenarios under the selected features, or every project scenario when + * nothing is selected. The search bar then filters that list. + */ + public List visibleScenarios() { + List pool = new ArrayList<>(); + for (FeatureEntry feature : features) { + if (selectedFeatures.isEmpty() || selectedFeatures.contains(feature.file())) { + pool.addAll(feature.scenarios()); + } + } + String query = scenarioQuery.strip().toLowerCase(Locale.ROOT); + if (!query.isEmpty()) { + pool.removeIf(scenario -> !matches(scenario, query)); + } + pool.sort(Comparator + .comparing(ScenarioEntry::featureName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(ScenarioEntry::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(pool); + } + + public FeatureEntry feature(Path file) { + Path key = file.toAbsolutePath().normalize(); + return features.stream() + .filter(feature -> feature.file().equals(key)) + .findFirst() + .orElse(null); + } + + static List discover(Path projectRoot, WorkbenchManifest manifest) { + Path root = projectRoot.toAbsolutePath().normalize(); + LinkedHashSet searchRoots = new LinkedHashSet<>(); + addIfDirectory(searchRoots, root.resolve("src").resolve("test").resolve("resources").resolve("features")); + addIfDirectory(searchRoots, root.resolve("src").resolve("test").resolve("resources")); + if (manifest != null) { + for (String source : manifest.sourceRoots()) { + if (source == null || source.isBlank()) continue; + Path sourceRoot = Path.of(source).toAbsolutePath().normalize(); + addIfDirectory(searchRoots, sourceRoot); + addIfDirectory(searchRoots, sourceRoot.resolve("features")); + } + if (manifest.liveOutput() != null && !manifest.liveOutput().isBlank()) { + Path live = Path.of(manifest.liveOutput()).toAbsolutePath().normalize(); + addIfDirectory(searchRoots, live.resolve("features")); + } + } + for (Path configured : configuredFeatureRoots(root)) { + addIfDirectory(searchRoots, configured); + } + + LinkedHashSet files = new LinkedHashSet<>(); + for (Path searchRoot : searchRoots) { + collectFeatureFiles(searchRoot, files); + } + + List entries = new ArrayList<>(); + LinkedHashSet seen = new LinkedHashSet<>(); + for (Path file : files) { + Path absolute = file.toAbsolutePath().normalize(); + if (!seen.add(absolute)) continue; + try { + entries.add(readFeature(root, absolute)); + } catch (IOException ignored) { + // Skip unreadable files; the picker should not fail the whole catalog. + } + } + entries.sort(Comparator.comparing(FeatureEntry::relativePath, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(entries); + } + + static List configuredFeatureRoots(Path projectRoot) { + List roots = new ArrayList<>(); + List propertyFiles = List.of( + projectRoot.resolve("src").resolve("test").resolve("resources").resolve("pickleball.properties"), + projectRoot.resolve("src").resolve("main").resolve("resources").resolve("pickleball.properties"), + projectRoot.resolve("pickleball.properties") + ); + for (Path file : propertyFiles) { + if (!Files.isRegularFile(file)) continue; + try { + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + String trimmed = line.strip(); + if (trimmed.startsWith("#") || !trimmed.contains("pkb_features")) continue; + String value = featurePathValue(trimmed); + Path resolved = resolveConfiguredFeatures(projectRoot, value); + if (resolved != null) roots.add(resolved); + } + } catch (IOException ignored) { + // Configuration is optional discovery input. + } + } + return roots; + } + + static String featurePathValue(String line) { + int equals = line.indexOf('='); + if (equals < 0) return ""; + String raw = line.substring(equals + 1).strip(); + if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length() >= 2) { + raw = raw.substring(1, raw.length() - 1); + } + return raw; + } + + static Path resolveConfiguredFeatures(Path projectRoot, String value) { + if (value == null || value.isBlank()) return null; + String path = value.strip(); + if (path.startsWith("classpath:")) { + String remainder = path.substring("classpath:".length()).replace('\\', '/'); + while (remainder.startsWith("/")) remainder = remainder.substring(1); + Path testResources = projectRoot.resolve("src").resolve("test").resolve("resources"); + return remainder.isBlank() ? testResources : testResources.resolve(remainder); + } + if (path.startsWith("file:")) { + path = path.substring("file:".length()); + } + Path configured = Path.of(path); + return configured.isAbsolute() ? configured.normalize() : projectRoot.resolve(configured).normalize(); + } + + static FeatureEntry readFeature(Path projectRoot, Path file) throws IOException { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + String featureName = ""; + String currentScenario = null; + int scenarioStart = -1; + List header = new ArrayList<>(); + List scenarios = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + String trimmed = lines.get(i).strip(); + if (startsWithKeyword(trimmed, "Feature:")) { + featureName = trimmed.substring("Feature:".length()).strip(); + header.add(lines.get(i)); + } else if (startsWithKeyword(trimmed, "Scenario Outline:") || startsWithKeyword(trimmed, "Scenario:")) { + if (currentScenario != null) { + scenarios.add(scenario(projectRoot, file, featureName, currentScenario, scenarioStart, i - 1, lines, header)); + } + currentScenario = trimmed.contains(":") + ? trimmed.substring(trimmed.indexOf(':') + 1).strip() + : trimmed; + scenarioStart = i; + } + } + if (currentScenario != null) { + scenarios.add(scenario(projectRoot, file, featureName, currentScenario, scenarioStart, lines.size() - 1, lines, header)); + } + Path relative = relativeTo(projectRoot, file); + String relativePath = relative.toString().replace('\\', '/'); + Path parent = relative.getParent(); + return new FeatureEntry( + file.toAbsolutePath().normalize(), + relativePath, + featureName, + parent == null ? "" : parent.toString().replace('\\', '/'), + file.getFileName().toString(), + scenarios + ); + } + + private static ScenarioEntry scenario( + Path projectRoot, + Path file, + String featureName, + String name, + int start, + int end, + List lines, + List header + ) { + List body = new ArrayList<>(); + if (!header.isEmpty()) { + body.addAll(header); + if (body.getLast().isBlank() == false) body.add(""); + } + for (int i = start; i <= end && i < lines.size(); i++) { + body.add(lines.get(i)); + } + while (!body.isEmpty() && body.getLast().isBlank()) { + body.removeLast(); + } + return new ScenarioEntry( + name, + featureName, + file.toAbsolutePath().normalize(), + relativeTo(projectRoot, file).toString().replace('\\', '/'), + start + 1, + end + 1, + body + ); + } + + private static boolean matches(ScenarioEntry scenario, String query) { + return scenario.name().toLowerCase(Locale.ROOT).contains(query) + || scenario.featureName().toLowerCase(Locale.ROOT).contains(query) + || scenario.relativePath().toLowerCase(Locale.ROOT).contains(query); + } + + private static boolean startsWithKeyword(String trimmed, String keyword) { + return trimmed.startsWith(keyword); + } + + private static void addIfDirectory(Set roots, Path path) { + if (path != null && Files.isDirectory(path)) { + roots.add(path.toAbsolutePath().normalize()); + } + } + + private static void collectFeatureFiles(Path root, Set files) { + if (!Files.isDirectory(root)) return; + try (Stream walk = Files.walk(root, 8)) { + walk.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".feature")) + .forEach(path -> files.add(path.toAbsolutePath().normalize())); + } catch (IOException ignored) { + // A single unreadable directory must not hide the rest of the catalog. + } + } + + private static Path relativeTo(Path projectRoot, Path file) { + try { + return projectRoot.relativize(file.toAbsolutePath().normalize()); + } catch (IllegalArgumentException ignored) { + return file.getFileName(); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java new file mode 100644 index 00000000..7f582e66 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java @@ -0,0 +1,295 @@ +package tools.dscode.workbench.diagnostics; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** + * Read-only navigator over Pickleball's retained diagnostic artifacts. + * + *

Layer order matches the repository evidence protocol: run-catalog, + * run-index/clusters, scenario summary, events.jsonl, comparison/fingerprint + * metadata, PNG, then raw trace only if needed. Workbench does not create a + * competing diagnostic store or synthesize retained-run data.

+ */ +public final class DiagnosticEvidenceNavigator { + public enum Layer { + CATALOG, + RUN_INDEX, + CLUSTERS, + SUMMARY, + EVENTS, + COMPARISON, + SCREENSHOT, + TRACE + } + + public record CatalogRun(String runId, Path runRoot, JsonNode raw) { } + + public record ScreenshotFrame( + Path file, + String stepText, + String capturedAt, + String scenarioId + ) { } + + public record Timeline( + Path runRoot, + JsonNode runIndex, + JsonNode clusters, + List frames + ) { + public Timeline { + frames = List.copyOf(frames == null ? List.of() : frames); + } + } + + public record LayerView(Layer layer, Path path, boolean present, String excerpt) { } + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final Path projectRoot; + private final Path diagnosticRoot; + + public DiagnosticEvidenceNavigator(Path projectRoot) { + this(projectRoot, defaultDiagnosticRoot(projectRoot)); + } + + public DiagnosticEvidenceNavigator(Path projectRoot, Path diagnosticRoot) { + this.projectRoot = projectRoot.toAbsolutePath().normalize(); + this.diagnosticRoot = diagnosticRoot == null + ? defaultDiagnosticRoot(this.projectRoot) + : diagnosticRoot.toAbsolutePath().normalize(); + } + + public static Path defaultDiagnosticRoot(Path projectRoot) { + return projectRoot.toAbsolutePath().normalize().resolve("reports").resolve("diagnostic-runs"); + } + + public Path projectRoot() { + return projectRoot; + } + + public Path diagnosticRoot() { + return diagnosticRoot; + } + + public boolean available() { + return Files.isRegularFile(diagnosticRoot.resolve("run-catalog.json")); + } + + public List catalogRuns() { + Path catalog = diagnosticRoot.resolve("run-catalog.json"); + if (!Files.isRegularFile(catalog)) return List.of(); + JsonNode root = readJson(catalog); + List runs = new ArrayList<>(); + for (JsonNode node : catalogItems(root)) { + String runId = text(node, "runId", "id", "run"); + if (runId.isBlank()) continue; + Path runRoot = diagnosticRoot.resolve(runId); + runs.add(new CatalogRun(runId, runRoot, node)); + } + return List.copyOf(runs); + } + + public Timeline timeline(Path runRoot) { + Path root = runRoot.toAbsolutePath().normalize(); + JsonNode index = readJsonIfPresent(root.resolve("run-index.json")); + JsonNode clusters = readJsonIfPresent(root.resolve("clusters.json")); + List frames = new ArrayList<>(); + Path scenarios = root.resolve("scenarios"); + if (Files.isDirectory(scenarios)) { + try (var directories = Files.list(scenarios)) { + directories.filter(Files::isDirectory).forEach(scenarioDir -> + frames.addAll(framesForScenario(scenarioDir))); + } catch (IOException ignored) { + // Missing scenario folders are a retention gap, not a Workbench store. + } + } + frames.sort(Comparator + .comparing((ScreenshotFrame frame) -> frame.capturedAt() == null ? "" : frame.capturedAt()) + .thenComparing(frame -> frame.file().getFileName().toString())); + return new Timeline(root, index, clusters, frames); + } + + public List layers(Path runRoot, String scenarioId) { + Path root = runRoot.toAbsolutePath().normalize(); + Path scenarioDir = scenarioId == null || scenarioId.isBlank() + ? null + : root.resolve("scenarios").resolve(scenarioId); + List layers = new ArrayList<>(); + layers.add(layer(Layer.CATALOG, diagnosticRoot.resolve("run-catalog.json"), 40)); + layers.add(layer(Layer.RUN_INDEX, root.resolve("run-index.json"), 40)); + layers.add(layer(Layer.CLUSTERS, root.resolve("clusters.json"), 40)); + if (scenarioDir != null) { + layers.add(layer(Layer.SUMMARY, scenarioDir.resolve("summary.json"), 40)); + layers.add(layer(Layer.EVENTS, scenarioDir.resolve("events.jsonl"), 20)); + Path comparison = firstExisting( + scenarioDir.resolve("comparisonToPrevious.json"), + scenarioDir.resolve("comparison-to-previous.json") + ); + layers.add(layer(Layer.COMPARISON, comparison, 20)); + Path screenshots = scenarioDir.resolve("screenshots"); + layers.add(new LayerView( + Layer.SCREENSHOT, + screenshots, + Files.isDirectory(screenshots), + Files.isDirectory(screenshots) ? "PNG evidence directory" : "No screenshot directory" + )); + Path trace = firstExisting(scenarioDir.resolve("trace.jsonl.gz"), scenarioDir.resolve("trace.jsonl")); + layers.add(layer(Layer.TRACE, trace, 8)); + } + return List.copyOf(layers); + } + + public String readExcerpt(Path path, int maxLines) { + if (path == null || !Files.isRegularFile(path)) return ""; + try { + List lines = Files.readAllLines(path); + int limit = Math.max(1, maxLines); + if (lines.size() <= limit) return String.join("\n", lines); + return String.join("\n", lines.subList(0, limit)) + "\n..."; + } catch (IOException failure) { + return ""; + } + } + + private List framesForScenario(Path scenarioDir) { + Path screenshots = scenarioDir.resolve("screenshots"); + if (!Files.isDirectory(screenshots)) return List.of(); + JsonNode summary = readJsonIfPresent(scenarioDir.resolve("summary.json")); + List eventSteps = eventStepTexts(scenarioDir.resolve("events.jsonl")); + List pngs = new ArrayList<>(); + try (var files = Files.list(screenshots)) { + files.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".png")) + .sorted() + .forEach(pngs::add); + } catch (IOException ignored) { + return List.of(); + } + List frames = new ArrayList<>(); + for (int i = 0; i < pngs.size(); i++) { + Path png = pngs.get(i); + String step = stepForScreenshot(png, summary, eventSteps, i); + frames.add(new ScreenshotFrame( + png, + step, + fileTime(png), + scenarioDir.getFileName().toString() + )); + } + return frames; + } + + private static String stepForScreenshot(Path png, JsonNode summary, List eventSteps, int index) { + String named = screenshotStepFromSummary(summary, png.getFileName().toString()); + if (named != null && !named.isBlank()) return named; + if (index < eventSteps.size()) return eventSteps.get(index); + if (summary != null && summary.hasNonNull("lastStepText")) { + return summary.get("lastStepText").asText(); + } + return ""; + } + + private static String screenshotStepFromSummary(JsonNode summary, String fileName) { + if (summary == null) return ""; + JsonNode screenshots = summary.get("screenshots"); + if (screenshots == null || !screenshots.isArray()) return ""; + for (JsonNode item : screenshots) { + String name = text(item, "file", "path", "name"); + if (name.endsWith(fileName)) { + return text(item, "stepText", "step", "gherkin", "phrase"); + } + } + return ""; + } + + private static List eventStepTexts(Path events) { + if (!Files.isRegularFile(events)) return List.of(); + List steps = new ArrayList<>(); + try { + for (String line : Files.readAllLines(events)) { + if (line.isBlank()) continue; + JsonNode node = JSON.readTree(line); + String step = text(node, "stepText", "step", "gherkin", "phraseText"); + if (!step.isBlank()) steps.add(step); + } + } catch (IOException ignored) { + return List.of(); + } + return steps; + } + + private LayerView layer(Layer layer, Path path, int excerptLines) { + boolean present = path != null && Files.exists(path); + return new LayerView(layer, path, present, present ? readExcerpt(path, excerptLines) : ""); + } + + private static Path firstExisting(Path... paths) { + for (Path path : paths) { + if (path != null && Files.exists(path)) return path; + } + return paths.length == 0 ? null : paths[0]; + } + + private static List catalogItems(JsonNode root) { + List items = new ArrayList<>(); + if (root == null) return items; + if (root.isArray()) { + root.forEach(items::add); + return items; + } + for (String field : List.of("runs", "entries", "items")) { + JsonNode value = root.get(field); + if (value != null && value.isArray()) { + value.forEach(items::add); + return items; + } + } + if (root.has("runId") || root.has("id")) items.add(root); + return items; + } + + private static JsonNode readJson(Path file) { + try { + return JSON.readTree(file.toFile()); + } catch (IOException failure) { + throw new IllegalStateException("Could not read diagnostic JSON: " + file, failure); + } + } + + private static JsonNode readJsonIfPresent(Path file) { + if (!Files.isRegularFile(file)) return null; + try { + return JSON.readTree(file.toFile()); + } catch (IOException ignored) { + return null; + } + } + + private static String text(JsonNode node, String... fields) { + if (node == null) return ""; + for (String field : fields) { + JsonNode value = node.get(field); + if (value != null && !value.isNull() && !value.asText().isBlank()) { + return value.asText(); + } + } + return ""; + } + + private static String fileTime(Path file) { + try { + return Files.getLastModifiedTime(file).toString(); + } catch (IOException ignored) { + return ""; + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java new file mode 100644 index 00000000..c3cea76b --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java @@ -0,0 +1,143 @@ +package tools.dscode.workbench.mapping; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Structured view of one worker-supplied NodeMap snapshot. Edits stay in this + * presentation model until they are sent through {@code mappingPut} or + * {@code mappingRestore}. + */ +public final class MappingTreeModel { + public record Property( + String key, + MappingValueCodec.ValueType type, + String text, + Object value + ) { + public Property { + key = key == null ? "" : key; + type = type == null ? MappingValueCodec.ValueType.STRING : type; + text = text == null ? "" : text; + } + } + + private final String mapReference; + private final String mapType; + private final boolean restorable; + private final List properties; + + public MappingTreeModel( + String mapReference, + String mapType, + boolean restorable, + Map values + ) { + this.mapReference = mapReference == null ? "" : mapReference; + this.mapType = mapType == null ? "" : mapType; + this.restorable = restorable; + this.properties = propertiesFrom(values); + } + + public String mapReference() { + return mapReference; + } + + public String mapType() { + return mapType; + } + + public boolean restorable() { + return restorable; + } + + public List properties() { + return List.copyOf(properties); + } + + public Map values() { + Map values = new LinkedHashMap<>(); + for (Property property : properties) { + values.put(property.key(), property.value()); + } + return values; + } + + public MappingTreeModel upsert(String key, MappingValueCodec.ValueType type, String text) { + Objects.requireNonNull(type, "type"); + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("Mapping property key must not be blank."); + } + Object value = MappingValueCodec.decode(type, text); + List updated = new ArrayList<>(); + boolean replaced = false; + for (Property property : properties) { + if (property.key().equals(key)) { + updated.add(new Property(key, type, MappingValueCodec.encode(value), value)); + replaced = true; + } else { + updated.add(property); + } + } + if (!replaced) { + updated.add(new Property(key, type, MappingValueCodec.encode(value), value)); + } + return withProperties(updated); + } + + public MappingTreeModel rename(String fromKey, String toKey) { + if (fromKey == null || fromKey.isBlank()) { + throw new IllegalArgumentException("Original Mapping key must not be blank."); + } + if (toKey == null || toKey.isBlank()) { + throw new IllegalArgumentException("New Mapping key must not be blank."); + } + List updated = new ArrayList<>(); + boolean found = false; + for (Property property : properties) { + if (property.key().equals(fromKey)) { + updated.add(new Property(toKey, property.type(), property.text(), property.value())); + found = true; + } else { + updated.add(property); + } + } + if (!found) { + throw new IllegalArgumentException("Unknown Mapping key: " + fromKey); + } + return withProperties(updated); + } + + public MappingTreeModel remove(String key) { + List updated = new ArrayList<>(); + for (Property property : properties) { + if (!property.key().equals(key)) updated.add(property); + } + return withProperties(updated); + } + + private MappingTreeModel withProperties(List properties) { + MappingTreeModel copy = new MappingTreeModel(mapReference, mapType, restorable, Map.of()); + copy.properties.clear(); + copy.properties.addAll(properties); + return copy; + } + + private static List propertiesFrom(Map values) { + List properties = new ArrayList<>(); + if (values == null) return properties; + for (Map.Entry entry : values.entrySet()) { + MappingValueCodec.ValueType type = MappingValueCodec.inferType(entry.getValue()); + properties.add(new Property( + Objects.toString(entry.getKey(), ""), + type, + MappingValueCodec.encode(entry.getValue()), + entry.getValue() + )); + } + return properties; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java new file mode 100644 index 00000000..a1d60620 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java @@ -0,0 +1,173 @@ +package tools.dscode.workbench.mapping; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Encodes Mapping property values for the existing {@code mappingPut} / + * {@code mappingRestore} services. This is presentation-side typing only; + * Workbench does not keep a second Mapping store. + */ +public final class MappingValueCodec { + public enum ValueType { + STRING, + NUMERIC, + BOOLEAN, + OBJECT_JSON, + OBJECT_XML + } + + private static final ObjectMapper JSON = new ObjectMapper(); + + private MappingValueCodec() { + } + + public static ValueType parseType(String raw) { + if (raw == null || raw.isBlank()) return ValueType.STRING; + String normalized = raw.trim().toUpperCase(Locale.ROOT) + .replace('-', '_') + .replace(' ', '_'); + return switch (normalized) { + case "STRING", "TEXT" -> ValueType.STRING; + case "NUMERIC", "NUMBER", "INTEGER", "INT", "LONG", "DOUBLE" -> ValueType.NUMERIC; + case "BOOLEAN", "BOOL" -> ValueType.BOOLEAN; + case "OBJECT_JSON", "JSON", "OBJECT_AS_JSON" -> ValueType.OBJECT_JSON; + case "OBJECT_XML", "XML", "OBJECT_AS_XML" -> ValueType.OBJECT_XML; + default -> throw new IllegalArgumentException("Unsupported Mapping value type: " + raw); + }; + } + + public static ValueType inferType(Object value) { + if (value instanceof Boolean) return ValueType.BOOLEAN; + if (value instanceof Number) return ValueType.NUMERIC; + if (value instanceof Map || value instanceof List) return ValueType.OBJECT_JSON; + return ValueType.STRING; + } + + public static Object decode(ValueType type, String text) { + Objects.requireNonNull(type, "type"); + String value = text == null ? "" : text; + return switch (type) { + case STRING -> value; + case NUMERIC -> decodeNumeric(value); + case BOOLEAN -> decodeBoolean(value); + case OBJECT_JSON -> decodeJson(value); + case OBJECT_XML -> decodeXml(value); + }; + } + + public static Object decode(String type, String text) { + return decode(parseType(type), text); + } + + public static String encode(Object value) { + if (value == null) return ""; + if (value instanceof Map || value instanceof List) { + try { + return JSON.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (JsonProcessingException failure) { + return Objects.toString(value); + } + } + return Objects.toString(value); + } + + private static Number decodeNumeric(String text) { + String value = text.strip(); + if (value.isBlank()) { + throw new IllegalArgumentException("Numeric Mapping value must not be blank."); + } + if (value.contains(".") || value.contains("e") || value.contains("E")) { + return Double.valueOf(value); + } + try { + return Long.valueOf(value); + } catch (NumberFormatException ignored) { + return Double.valueOf(value); + } + } + + private static Boolean decodeBoolean(String text) { + String value = text.strip(); + if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) { + return Boolean.valueOf(value); + } + throw new IllegalArgumentException("Boolean Mapping value must be true or false."); + } + + private static Object decodeJson(String text) { + String value = text.strip(); + if (value.isBlank()) return new LinkedHashMap(); + try { + if (value.startsWith("[")) { + return JSON.readValue(value, new TypeReference>() { }); + } + if (value.startsWith("{")) { + return JSON.readValue(value, new TypeReference>() { }); + } + return JSON.readValue(value, Object.class); + } catch (JsonProcessingException failure) { + throw new IllegalArgumentException("Invalid JSON Mapping value.", failure); + } + } + + private static Object decodeXml(String text) { + String value = text.strip(); + if (value.isBlank()) return new LinkedHashMap(); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setExpandEntityReferences(false); + Document document = factory.newDocumentBuilder() + .parse(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8))); + return elementToMap(document.getDocumentElement()); + } catch (Exception failure) { + throw new IllegalArgumentException("Invalid XML Mapping value.", failure); + } + } + + private static Map elementToMap(Element element) { + Map map = new LinkedHashMap<>(); + map.put("_name", element.getTagName()); + NamedNodeMap attributes = element.getAttributes(); + if (attributes != null && attributes.getLength() > 0) { + Map attrs = new LinkedHashMap<>(); + for (int i = 0; i < attributes.getLength(); i++) { + Node attribute = attributes.item(i); + attrs.put(attribute.getNodeName(), attribute.getNodeValue()); + } + map.put("_attributes", attrs); + } + NodeList children = element.getChildNodes(); + List nested = new ArrayList<>(); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + nested.add(elementToMap((Element) child)); + } else if (child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE) { + text.append(child.getTextContent()); + } + } + String body = text.toString().strip(); + if (!body.isEmpty()) map.put("_text", body); + if (!nested.isEmpty()) map.put("_children", nested); + return map; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java new file mode 100644 index 00000000..7302d866 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java @@ -0,0 +1,261 @@ +package tools.dscode.workbench.player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Nested block view of a live Gherkin buffer. + * + *

Blocks are Gherkin text. Nesting is the existing Pickleball + * leading-colon grammar; this class does not compile to another language and + * does not strip {@code Given}/{@code When}/{@code Then}.

+ */ +public final class GherkinBlockDocument { + public record Block(long id, String text, int nestLevel, List children) { + public Block { + Objects.requireNonNull(text, "text"); + children = List.copyOf(children == null ? List.of() : children); + } + + public boolean structural() { + String trimmed = stripNestPrefix(text); + return startsWithAny(trimmed, + "Feature:", "Rule:", "Background:", "Scenario:", "Scenario Outline:", "Examples:"); + } + + public boolean nestable() { + return !structural() && !text.strip().startsWith("#") && !text.isBlank(); + } + } + + private final List roots; + + public GherkinBlockDocument(List roots) { + this.roots = List.copyOf(roots == null ? List.of() : roots); + } + + public static GherkinBlockDocument fromPlayer(LiveScenarioPlayer player) { + return fromLines(player.lines()); + } + + public static GherkinBlockDocument fromLines(List lines) { + List parsed = new ArrayList<>(); + for (LiveScenarioPlayer.Line line : lines) { + parsed.add(new Parsed(line.id(), line.text(), nestLevel(line.text()), stripNestPrefix(line.text()))); + } + return new GherkinBlockDocument(buildTree(parsed)); + } + + public static GherkinBlockDocument fromTexts(List texts) { + List lines = new ArrayList<>(); + long id = 1; + for (String text : texts) { + String value = text == null ? "" : text; + lines.add(new LiveScenarioPlayer.Line(id++, value, LiveScenarioPlayer.LineType.TEXT)); + } + return fromLines(lines); + } + + public List roots() { + return roots; + } + + public List toLines() { + List lines = new ArrayList<>(); + write(roots, 0, lines); + return List.copyOf(lines); + } + + public void applyTo(LiveScenarioPlayer player) { + player.replaceDocument(toLines()); + } + + public Optional find(long id) { + return find(roots, id); + } + + public GherkinBlockDocument updateText(long id, String text) { + String value = text == null ? "" : text; + return new GherkinBlockDocument(map(roots, id, block -> + new Block(block.id(), value, block.nestLevel(), block.children()))); + } + + /** + * Moves {@code id} so it becomes a child of {@code parentId} (or a root + * when {@code parentId} is empty) at {@code index}. Nested IF/ELSE and + * nested steps snap as parent/child this way. + */ + public GherkinBlockDocument move(long id, OptionalLong parentId, int index) { + Block moving = find(id).orElseThrow(() -> new IllegalArgumentException("Unknown block id: " + id)); + if (parentId.isPresent() && contains(moving, parentId.getAsLong())) { + throw new IllegalArgumentException("Cannot nest a block inside itself."); + } + List without = remove(roots, id); + Block relocated = new Block(moving.id(), moving.text(), 0, moving.children()); + List inserted = insert(without, parentId, Math.max(0, index), relocated); + return new GherkinBlockDocument(inserted); + } + + public Optional playheadBlock(LiveScenarioPlayer player) { + if (player.playheadId().isEmpty()) return Optional.empty(); + return find(player.playheadId().getAsLong()); + } + + private static void write(List blocks, int level, List lines) { + for (Block block : blocks) { + lines.add(applyNest(block.text(), level)); + write(block.children(), level + 1, lines); + } + } + + private static List buildTree(List parsed) { + class Mutable { + final long id; + final String text; + final int level; + final List children = new ArrayList<>(); + + Mutable(long id, String text, int level) { + this.id = id; + this.text = text; + this.level = level; + } + + Block freeze() { + return new Block(id, text, level, children.stream().map(Mutable::freeze).toList()); + } + } + + List roots = new ArrayList<>(); + List stack = new ArrayList<>(); + for (Parsed item : parsed) { + Mutable created = new Mutable(item.id(), item.body(), item.level()); + while (!stack.isEmpty() && stack.getLast().level >= item.level()) { + stack.removeLast(); + } + if (stack.isEmpty()) { + roots.add(created); + } else { + stack.getLast().children.add(created); + } + stack.add(created); + } + return roots.stream().map(Mutable::freeze).toList(); + } + + private static List map(List blocks, long id, java.util.function.Function mapper) { + List updated = new ArrayList<>(blocks.size()); + for (Block block : blocks) { + List children = map(block.children(), id, mapper); + Block current = new Block(block.id(), block.text(), block.nestLevel(), children); + updated.add(current.id() == id ? mapper.apply(current) : current); + } + return updated; + } + + private static List remove(List blocks, long id) { + List updated = new ArrayList<>(); + for (Block block : blocks) { + if (block.id() == id) continue; + updated.add(new Block(block.id(), block.text(), block.nestLevel(), remove(block.children(), id))); + } + return updated; + } + + private static List insert(List blocks, OptionalLong parentId, int index, Block moving) { + if (parentId.isEmpty()) { + List roots = new ArrayList<>(blocks); + roots.add(Math.min(index, roots.size()), moving); + return roots; + } + List updated = new ArrayList<>(blocks.size()); + for (Block block : blocks) { + if (block.id() == parentId.getAsLong()) { + List children = new ArrayList<>(block.children()); + children.add(Math.min(index, children.size()), moving); + updated.add(new Block(block.id(), block.text(), block.nestLevel(), children)); + } else { + updated.add(new Block( + block.id(), + block.text(), + block.nestLevel(), + insert(block.children(), parentId, index, moving) + )); + } + } + return updated; + } + + private static Optional find(List blocks, long id) { + for (Block block : blocks) { + if (block.id() == id) return Optional.of(block); + Optional nested = find(block.children(), id); + if (nested.isPresent()) return nested; + } + return Optional.empty(); + } + + private static boolean contains(Block block, long id) { + if (block.id() == id) return true; + for (Block child : block.children()) { + if (contains(child, id)) return true; + } + return false; + } + + static int nestLevel(String text) { + String trimmed = text == null ? "" : text.stripLeading(); + int level = 0; + while (trimmed.startsWith(":")) { + level++; + trimmed = trimmed.substring(1).stripLeading(); + } + return level; + } + + static String stripNestPrefix(String text) { + if (text == null) return ""; + String indent = leadingWhitespace(text); + String trimmed = text.stripLeading(); + while (trimmed.startsWith(":")) { + trimmed = trimmed.substring(1).stripLeading(); + } + if (text.isBlank()) return text; + if (indent.isEmpty()) return trimmed; + return trimmed; + } + + static String applyNest(String body, int level) { + String content = stripNestPrefix(body); + if (content.isBlank() || content.startsWith("#")) { + return body == null ? "" : body; + } + if (level <= 0) { + return body != null && body.startsWith(" ") ? preserveIndent(body, content) : content; + } + return " " + ":".repeat(level) + " " + content; + } + + private static String preserveIndent(String original, String content) { + return leadingWhitespace(original) + content; + } + + private static String leadingWhitespace(String text) { + if (text == null) return ""; + int i = 0; + while (i < text.length() && Character.isWhitespace(text.charAt(i))) i++; + return text.substring(0, i); + } + + private static boolean startsWithAny(String value, String... prefixes) { + for (String prefix : prefixes) { + if (value.startsWith(prefix)) return true; + } + return false; + } + + private record Parsed(long id, String original, int level, String body) { } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java new file mode 100644 index 00000000..e5c263cc --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java @@ -0,0 +1,89 @@ +package tools.dscode.workbench.player; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Headless play-header / block-buffer coordinator. Swing and WebView adapters + * report selection, playhead, and document changes here; execution still goes + * through {@code WorkbenchServices.executeStep}. + */ +public final class LivePlaybackCoordinator { + private final LiveScenarioPlayer player; + private java.nio.file.Path originFile; + + public LivePlaybackCoordinator(LiveScenarioPlayer player) { + this.player = Objects.requireNonNull(player, "player"); + } + + public LiveScenarioPlayer player() { + return player; + } + + public Optional originFile() { + return Optional.ofNullable(originFile); + } + + public void clearOrigin() { + originFile = null; + } + + public void loadDefaultDemo() { + originFile = null; + player.loadDocument(LiveScenarioPlayer.DEFAULT_DEMO_SCENARIO); + } + + public void loadScenario(List lines, java.nio.file.Path origin) { + originFile = origin; + player.loadDocument(lines); + } + + public GherkinBlockDocument blocks() { + return GherkinBlockDocument.fromPlayer(player); + } + + public void replaceFromBlocks(GherkinBlockDocument document) { + Objects.requireNonNull(document, "document").applyTo(player); + } + + public void replaceFromLines(List lines) { + player.replaceDocument(lines); + } + + public void seek(long lineId) { + player.clickLine(lineId); + } + + public void playFromStart() { + player.startFromBeginning(); + } + + public void playFromHere() { + player.startFromSelectedStep(); + } + + public void pause() { + player.pause(); + } + + public void stop() { + player.stop(); + } + + public void stepOnly() { + player.pauseForIsolatedExecution(); + } + + public LiveScenarioPlayer.Line insertAndMaybeContinue(String text) { + return player.insertStep(text); + } + + public boolean waitingForStep() { + return player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP; + } + + public boolean running() { + return player.state() == LiveScenarioPlayer.State.RUNNING; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java index 2d506a9e..badbb368 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java @@ -80,6 +80,27 @@ public static LiveScenarioPlayer interactiveBuffer() { return new LiveScenarioPlayer(DEFAULT_DEMO_SCENARIO); } + /** + * Replaces the session buffer with a newly loaded scenario and returns to + * {@link State#STOPPED}. Used by the feature/scenario picker. The origin + * file is presentation metadata only; this model never writes {@code .feature} + * files. + */ + public void loadDocument(List texts) { + lines.clear(); + nextId = 1; + selectedId = null; + playheadId = null; + lastExecutedId = null; + executionIndex = 0; + state = State.STOPPED; + List incoming = texts == null || texts.isEmpty() ? List.of("") : texts; + for (String text : incoming) { + addInitialLine(text == null ? "" : text); + } + initializeCursors(); + } + public List lines() { return List.copyOf(lines); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java new file mode 100644 index 00000000..a6f03f07 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java @@ -0,0 +1,118 @@ +package tools.dscode.workbench.terminal; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Presentation buffer for worker/Workbench log text already written to files + * or returned by live execution. It does not invent log lines. + */ +public final class WorkerLogBuffer { + public enum Level { + TRACE, + DEBUG, + INFO, + WARNING, + ERROR + } + + public record Entry(long id, Level level, String raw, String message) { + public Entry { + Objects.requireNonNull(level, "level"); + raw = raw == null ? "" : raw; + message = message == null ? raw : message; + } + } + + private static final Pattern LEVEL_PATTERN = Pattern.compile( + "(?i)(?:\\[\\s*)?(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|SEVERE|FATAL)(?:\\s*\\])?(?:\\s*[:\\-])?\\s*(.*)" + ); + + private final List entries = new ArrayList<>(); + private long nextId = 1; + private Level minimum = Level.INFO; + + public void setMinimum(Level minimum) { + this.minimum = minimum == null ? Level.INFO : minimum; + } + + public Level minimum() { + return minimum; + } + + public void clear() { + entries.clear(); + } + + public List all() { + return List.copyOf(entries); + } + + public List visible() { + List visible = new ArrayList<>(); + for (Entry entry : entries) { + if (entry.level().ordinal() >= minimum.ordinal()) { + visible.add(entry); + } + } + return List.copyOf(visible); + } + + public List appendRaw(String text) { + if (text == null || text.isEmpty()) return List.of(); + List added = new ArrayList<>(); + for (String line : text.split("\\R", -1)) { + if (line.isBlank()) continue; + Entry entry = parse(line); + entries.add(entry); + added.add(entry); + } + return List.copyOf(added); + } + + public Entry parse(String line) { + String raw = line == null ? "" : line; + Matcher matcher = LEVEL_PATTERN.matcher(raw.strip()); + if (matcher.matches()) { + return new Entry(nextId++, levelOf(matcher.group(1)), raw, matcher.group(2)); + } + return new Entry(nextId++, inferUnmarked(raw), raw, raw); + } + + public static Level parseFilter(String raw) { + if (raw == null || raw.isBlank()) return Level.INFO; + String normalized = raw.trim().toUpperCase(Locale.ROOT); + if ("WARN".equals(normalized)) return Level.WARNING; + return Level.valueOf(normalized); + } + + private static Level levelOf(String token) { + String normalized = token.toUpperCase(Locale.ROOT); + return switch (normalized) { + case "TRACE" -> Level.TRACE; + case "DEBUG" -> Level.DEBUG; + case "INFO" -> Level.INFO; + case "WARN", "WARNING" -> Level.WARNING; + case "ERROR", "SEVERE", "FATAL" -> Level.ERROR; + default -> Level.INFO; + }; + } + + /** + * Unmarked worker output is treated as INFO so a scenario-run log remains + * visible at the default filter without fabricating a level the source + * did not print. + */ + private static Level inferUnmarked(String raw) { + String lower = raw.toLowerCase(Locale.ROOT); + if (lower.contains("error") || lower.contains("exception") || lower.contains("failed")) { + return Level.ERROR; + } + if (lower.contains("warn")) return Level.WARNING; + return Level.INFO; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java new file mode 100644 index 00000000..1c3b7c07 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java @@ -0,0 +1,15 @@ +package tools.dscode.workbench.terminal; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Existing Workbench worker stdout/stderr capture files. */ +public record WorkerLogFiles(Path stdout, Path stderr) { + public List existing() { + List files = new ArrayList<>(); + if (stdout != null) files.add(stdout); + if (stderr != null) files.add(stderr); + return List.copyOf(files); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java new file mode 100644 index 00000000..d0f7aad3 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java @@ -0,0 +1,229 @@ +package tools.dscode.workbench.ui; + +import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.JToggleButton; +import javax.swing.ListSelectionModel; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridLayout; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** Expandable left-rail feature/scenario picker over the project-owned catalog. */ +final class FeaturePickerPanel extends JPanel { + private final DefaultListModel featureModel = new DefaultListModel<>(); + private final DefaultListModel scenarioModel = new DefaultListModel<>(); + private final JList featureList = new JList<>(featureModel); + private final JList scenarioList = new JList<>(scenarioModel); + private final JTextField search = new JTextField(); + private final JToggleButton featureNameMode = new JToggleButton("Feature name"); + private final JToggleButton filePathMode = new JToggleButton("File path"); + private final JButton saveButton = WorkbenchTheme.flatButton("Save", "Write the live buffer back to the loaded .feature file"); + private final JLabel status = WorkbenchTheme.muted("No features selected — search all project scenarios"); + + private ConsumerFeatureCatalog catalog; + private final Map featuresByPath = new LinkedHashMap<>(); + private Consumer onScenario; + private Runnable onSave; + private boolean syncing; + + FeaturePickerPanel() { + super(new BorderLayout(8, 8)); + WorkbenchTheme.surface(this); + setBorder(WorkbenchTheme.cardBorder()); + setPreferredSize(new Dimension(280, 640)); + setMinimumSize(new Dimension(220, 320)); + + add(header(), BorderLayout.NORTH); + add(lists(), BorderLayout.CENTER); + add(status, BorderLayout.SOUTH); + + featureList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + featureList.setCellRenderer((list, value, index, selected, focused) -> { + JLabel label = new JLabel(value == null ? "" : value.browseLabel(catalog == null + ? ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME + : catalog.browseMode())); + label.setOpaque(true); + label.setBorder(new EmptyBorder(4, 6, 4, 6)); + label.setBackground(selected ? WorkbenchTheme.ACCENT_SOFT : WorkbenchTheme.SURFACE); + label.setForeground(WorkbenchTheme.TEXT); + return label; + }); + scenarioList.setCellRenderer((list, value, index, selected, focused) -> { + JLabel label = new JLabel(value == null ? "" : value.displayLabel()); + label.setOpaque(true); + label.setBorder(new EmptyBorder(4, 6, 4, 6)); + label.setBackground(selected ? WorkbenchTheme.PLAYHEAD : WorkbenchTheme.SURFACE); + label.setForeground(WorkbenchTheme.TEXT); + label.setToolTipText(value == null ? null : value.featureName() + " — " + value.relativePath()); + return label; + }); + featureList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + featureList.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + if (catalog == null) return; + int index = featureList.locationToIndex(event.getPoint()); + if (index < 0) return; + ConsumerFeatureCatalog.FeatureEntry feature = featureModel.get(index); + catalog.toggleFeature(feature.file()); + javax.swing.SwingUtilities.invokeLater(() -> { + syncFeatureSelection(); + refreshScenarios(); + }); + event.consume(); + } + }); + scenarioList.addListSelectionListener(event -> { + if (event.getValueIsAdjusting()) return; + ConsumerFeatureCatalog.ScenarioEntry selected = scenarioList.getSelectedValue(); + if (selected != null && onScenario != null) onScenario.accept(selected); + }); + search.getDocument().addDocumentListener(new DocumentListener() { + @Override public void insertUpdate(DocumentEvent event) { filterChanged(); } + @Override public void removeUpdate(DocumentEvent event) { filterChanged(); } + @Override public void changedUpdate(DocumentEvent event) { filterChanged(); } + }); + saveButton.setEnabled(false); + saveButton.addActionListener(event -> { + if (onSave != null) onSave.run(); + }); + } + + void setCatalog(ConsumerFeatureCatalog catalog) { + this.catalog = catalog; + featuresByPath.clear(); + featureModel.clear(); + if (catalog == null) { + refreshScenarios(); + return; + } + for (ConsumerFeatureCatalog.FeatureEntry feature : catalog.featuresForBrowse()) { + featuresByPath.put(feature.file(), feature); + featureModel.addElement(feature); + } + syncFeatureSelection(); + refreshScenarios(); + } + + private void syncFeatureSelection() { + if (catalog == null) return; + syncing = true; + try { + featureList.clearSelection(); + for (int i = 0; i < featureModel.size(); i++) { + if (catalog.selected(featureModel.get(i).file())) { + featureList.addSelectionInterval(i, i); + } + } + } finally { + syncing = false; + } + } + + void onScenarioSelected(Consumer onScenario) { + this.onScenario = onScenario; + } + + void onSave(Runnable onSave) { + this.onSave = onSave; + } + + void setSaveEnabled(boolean enabled) { + saveButton.setEnabled(enabled); + } + + private JPanel header() { + JPanel header = new JPanel(new BorderLayout(6, 6)); + header.setOpaque(false); + header.add(WorkbenchTheme.heading("Features"), BorderLayout.WEST); + + JPanel modes = new JPanel(new GridLayout(1, 2, 4, 0)); + modes.setOpaque(false); + featureNameMode.setSelected(true); + featureNameMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME)); + filePathMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FILE_PATH)); + modes.add(featureNameMode); + modes.add(filePathMode); + header.add(modes, BorderLayout.CENTER); + + JPanel south = new JPanel(new BorderLayout(6, 6)); + south.setOpaque(false); + search.setToolTipText("Filter scenarios. With no feature selected this searches the whole consumer project."); + south.add(search, BorderLayout.CENTER); + JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); + actions.setOpaque(false); + actions.add(saveButton); + south.add(actions, BorderLayout.EAST); + header.add(south, BorderLayout.SOUTH); + return header; + } + + private JPanel lists() { + JPanel lists = new JPanel(new GridLayout(2, 1, 0, 8)); + lists.setOpaque(false); + lists.add(labeled("Project features", featureList)); + lists.add(labeled("Scenarios", scenarioList)); + return lists; + } + + private JPanel labeled(String title, JList list) { + JPanel panel = new JPanel(new BorderLayout(0, 4)); + panel.setOpaque(false); + panel.add(WorkbenchTheme.muted(title), BorderLayout.NORTH); + JScrollPane scroll = new JScrollPane(list); + scroll.setBorder(BorderFactory.createLineBorder(WorkbenchTheme.BORDER)); + panel.add(scroll, BorderLayout.CENTER); + return panel; + } + + private void setMode(ConsumerFeatureCatalog.BrowseMode mode) { + featureNameMode.setSelected(mode == ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME); + filePathMode.setSelected(mode == ConsumerFeatureCatalog.BrowseMode.FILE_PATH); + if (catalog != null) { + catalog.setBrowseMode(mode); + setCatalog(catalog); + } + } + + private void filterChanged() { + if (catalog == null) return; + catalog.setScenarioQuery(search.getText()); + refreshScenarios(); + } + + private void refreshScenarios() { + scenarioModel.clear(); + if (catalog == null) { + status.setText("No synchronized consumer project."); + return; + } + for (ConsumerFeatureCatalog.ScenarioEntry scenario : catalog.visibleScenarios()) { + scenarioModel.addElement(scenario); + } + if (catalog.selectedFeatureFiles().isEmpty()) { + status.setText(catalog.scenarioQuery().isBlank() + ? "No features selected — showing all project scenarios" + : "Searching all project scenarios"); + } else { + status.setText(catalog.selectedFeatureFiles().size() + " feature(s) selected"); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java new file mode 100644 index 00000000..595a8809 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java @@ -0,0 +1,126 @@ +package tools.dscode.workbench.ui; + +import tools.dscode.workbench.terminal.WorkerLogBuffer; +import tools.dscode.workbench.terminal.WorkerLogFiles; + +import javax.swing.JComboBox; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.Timer; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.awt.Font; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Scenario-run log presentation over the existing worker stdout/stderr files. + * It never writes to MCP stdout and never fabricates log lines. + */ +final class TerminalPanel extends JPanel { + private final WorkerLogBuffer buffer = new WorkerLogBuffer(); + private final JTextArea area = new JTextArea(); + private final JComboBox filter = new JComboBox<>(WorkerLogBuffer.Level.values()); + private final Map positions = new LinkedHashMap<>(); + private WorkerLogFiles files; + private final Timer poller = new Timer(750, event -> poll()); + + TerminalPanel() { + super(new BorderLayout(8, 8)); + setBackground(WorkbenchTheme.SURFACE); + setBorder(new EmptyBorder(10, 12, 12, 12)); + + JPanel top = new JPanel(new BorderLayout(8, 0)); + top.setOpaque(false); + top.add(WorkbenchTheme.heading("Worker log"), BorderLayout.WEST); + filter.setSelectedItem(WorkerLogBuffer.Level.INFO); + filter.addActionListener(event -> { + buffer.setMinimum((WorkerLogBuffer.Level) filter.getSelectedItem()); + render(); + }); + top.add(filter, BorderLayout.EAST); + add(top, BorderLayout.NORTH); + + area.setEditable(false); + area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + area.setBackground(WorkbenchTheme.SURFACE); + area.setForeground(WorkbenchTheme.TEXT); + add(new JScrollPane(area), BorderLayout.CENTER); + poller.setRepeats(true); + } + + void start() { + if (!poller.isRunning()) poller.start(); + } + + void stop() { + poller.stop(); + } + + void setFiles(WorkerLogFiles files) { + this.files = files; + positions.clear(); + buffer.clear(); + render(); + poll(); + } + + void appendExecution(String heading, String output, String events) { + StringBuilder text = new StringBuilder(); + if (heading != null && !heading.isBlank()) text.append("[INFO] ").append(heading).append('\n'); + if (output != null && !output.isBlank()) text.append(output).append('\n'); + if (events != null && !events.isBlank()) text.append(events).append('\n'); + buffer.appendRaw(text.toString()); + render(); + } + + void noteGap(String message) { + area.setToolTipText(message); + } + + private void poll() { + if (files == null) return; + for (Path file : files.existing()) { + if (file == null || !Files.isRegularFile(file)) continue; + try { + String next = readSince(file); + if (!next.isEmpty()) buffer.appendRaw(next); + } catch (IOException ignored) { + // A rotating or briefly locked worker log must not crash the UI. + } + } + render(); + } + + private String readSince(Path file) throws IOException { + long previous = positions.getOrDefault(file, 0L); + long size = Files.size(file); + if (size < previous) previous = 0L; + if (size == previous) return ""; + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { + raf.seek(previous); + byte[] bytes = new byte[(int) Math.min(Integer.MAX_VALUE, size - previous)]; + raf.readFully(bytes); + positions.put(file, size); + return new String(bytes, StandardCharsets.UTF_8); + } + } + + private void render() { + StringBuilder text = new StringBuilder(); + for (WorkerLogBuffer.Entry entry : buffer.visible()) { + if (!text.isEmpty()) text.append('\n'); + text.append(entry.raw()); + } + if (!text.toString().equals(area.getText())) { + area.setText(text.toString()); + area.setCaretPosition(area.getDocument().getLength()); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index ca39b5dc..dd01a8fa 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -3,7 +3,19 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; +import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; +import tools.dscode.workbench.diagnostics.DiagnosticEvidenceNavigator; +import tools.dscode.workbench.mapping.MappingTreeModel; +import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.player.LivePlaybackCoordinator; import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.ui.web.DiagnosticExplorerHost; +import tools.dscode.workbench.ui.web.GherkinEditorHost; +import tools.dscode.workbench.ui.web.JavaFxSupport; +import tools.dscode.workbench.ui.web.MappingEditorHost; +import tools.dscode.workbench.ui.web.WebViewPanel; +import tools.dscode.workbench.ui.web.WorkbenchWebJson; import javax.swing.*; import javax.swing.border.EmptyBorder; @@ -18,6 +30,11 @@ import java.awt.event.MouseEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -31,7 +48,21 @@ final class WorkbenchFrame extends JFrame { private final WorkbenchUiController controller; private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + private final LivePlaybackCoordinator playback = new LivePlaybackCoordinator(player); private final ObjectMapper json = new ObjectMapper(); + private final FeaturePickerPanel picker = new FeaturePickerPanel(); + private final TerminalPanel terminal = new TerminalPanel(); + private final GherkinEditorHost gherkinHost = new GherkinEditorHost(); + private final MappingEditorHost mappingHost = new MappingEditorHost(); + private final DiagnosticExplorerHost diagnosticHost = new DiagnosticExplorerHost(); + private WebViewPanel gherkinView; + private WebViewPanel mappingView; + private WebViewPanel diagnosticView; + private JComponent pickerSplit; + private final JToggleButton pickerToggle = new JToggleButton("Features"); + private List mappingEntries = List.of(); + private MappingTreeModel mappingModel; + private DiagnosticEvidenceNavigator diagnosticNavigator; private static final Color PLAYHEAD_COLOR = new Color(255, 228, 150); private final JTextArea scenarioEditor = new JTextArea(); @@ -67,7 +98,7 @@ final class WorkbenchFrame extends JFrame { event -> saveEditedMapping() ); - private final JTextArea terminalArea = outputArea(); + private final JLabel webViewNote = WorkbenchTheme.muted(""); private WorkbenchUiController.State lastState; private ControlBridgeMappingSnapshot loadedMapping; @@ -90,35 +121,53 @@ final class WorkbenchFrame extends JFrame { this.controller = controller; mappingSaveTimer.setRepeats(false); + WorkbenchTheme.install(); + getContentPane().setBackground(WorkbenchTheme.BACKGROUND); setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); - setMinimumSize(new Dimension(1100, 720)); - setSize(1480, 900); + setMinimumSize(new Dimension(1180, 740)); + setSize(1560, 920); setLocationByPlatform(true); setJMenuBar(menuBar()); + configureWebViews(); - JPanel root = new JPanel(new BorderLayout(8, 8)); - root.setBorder(new EmptyBorder(8, 8, 8, 8)); + JPanel root = new JPanel(new BorderLayout(10, 10)); + root.setBackground(WorkbenchTheme.BACKGROUND); + root.setBorder(new EmptyBorder(10, 12, 10, 12)); root.add(playerBar(), BorderLayout.NORTH); - JSplitPane workspace = new JSplitPane( + JSplitPane editorAndRight = new JSplitPane( JSplitPane.HORIZONTAL_SPLIT, leftWorkspace(), rightWorkspace() ); - workspace.setResizeWeight(0.52); - workspace.setDividerLocation(760); - root.add(workspace, BorderLayout.CENTER); + WorkbenchTheme.styleSplit(editorAndRight); + editorAndRight.setResizeWeight(0.56); + editorAndRight.setDividerLocation(820); + + JSplitPane withPicker = new JSplitPane( + JSplitPane.HORIZONTAL_SPLIT, + picker, + editorAndRight + ); + WorkbenchTheme.styleSplit(withPicker); + withPicker.setResizeWeight(0.18); + withPicker.setDividerLocation(280); + pickerSplit = withPicker; + root.add(withPicker, BorderLayout.CENTER); root.add(footer(), BorderLayout.SOUTH); setContentPane(root); configureScenarioEditor(); configureStepEditor(); configureMappingEditor(); + configurePicker(); wirePlayerActions(); wireSessionActions(); syncScenarioView(); updatePlayerView(null); + refreshFeatureCatalog(); + terminal.start(); addWindowListener(new WindowAdapter() { @Override @@ -159,20 +208,29 @@ private JMenuBar menuBar() { private JPanel playerBar() { JPanel bar = new JPanel(new BorderLayout(12, 0)); + bar.setBackground(WorkbenchTheme.SURFACE); + bar.setBorder(WorkbenchTheme.cardBorder()); JPanel project = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); + project.setOpaque(false); + pickerToggle.setSelected(true); + pickerToggle.setFocusable(false); + pickerToggle.addActionListener(event -> togglePicker()); + project.add(pickerToggle); project.add(projectLabel); project.add(readinessLabel); bar.add(project, BorderLayout.WEST); - JPanel controls = new JPanel(new FlowLayout(FlowLayout.CENTER, 6, 0)); + JPanel controls = new JPanel(new FlowLayout(FlowLayout.CENTER, 8, 0)); + controls.setOpaque(false); controls.add(playButton); controls.add(pauseButton); controls.add(playerStopButton); bar.add(controls, BorderLayout.CENTER); JPanel state = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); - state.add(new JLabel("Status:")); + state.setOpaque(false); + state.add(WorkbenchTheme.muted("Status")); state.add(playerStatusLabel); bar.add(state, BorderLayout.EAST); return bar; @@ -187,17 +245,27 @@ private JComponent leftWorkspace() { private JComponent scenarioPanel() { JPanel panel = new JPanel(new BorderLayout(0, 4)); - panel.setBorder(BorderFactory.createTitledBorder("Live Scenario Editor")); + panel.setBackground(WorkbenchTheme.SURFACE); + panel.setBorder(WorkbenchTheme.cardBorder()); + panel.add(WorkbenchTheme.heading("Live Scenario Editor"), BorderLayout.NORTH); scenarioEditor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); scenarioEditor.setLineWrap(false); scenarioEditor.setTabSize(2); - panel.add(new JScrollPane(scenarioEditor), BorderLayout.CENTER); + JPanel editorHost = new JPanel(new CardLayout()); + editorHost.setOpaque(false); + editorHost.add(new JScrollPane(scenarioEditor), "text"); + if (gherkinView != null) { + editorHost.add(gherkinView, "web"); + ((CardLayout) editorHost.getLayout()).show(editorHost, "web"); + } + panel.add(editorHost, BorderLayout.CENTER); JPanel legend = new JPanel(new FlowLayout(FlowLayout.LEFT, 18, 2)); - legend.add(new JLabel("Click a step to move the playhead")); - legend.add(new JLabel("Global Play starts from the first step")); - legend.add(new JLabel("Edit Gherkin in place")); + legend.setOpaque(false); + legend.add(WorkbenchTheme.muted("Blocks are Gherkin text")); + legend.add(WorkbenchTheme.muted("Play starts from the first step")); + legend.add(WorkbenchTheme.muted("Click a block to move the playhead")); panel.add(legend, BorderLayout.SOUTH); return panel; } @@ -226,8 +294,11 @@ private JComponent stepPanel() { private JComponent rightWorkspace() { JTabbedPane tabs = new JTabbedPane(); tabs.addTab("Mapping", mappingPanel()); - tabs.addTab("Terminal", terminalPanel()); + tabs.addTab("Terminal", terminal); tabs.addTab("Diagnostic Log Explorer", diagnosticsPanel()); + tabs.addChangeListener(event -> { + if (tabs.getSelectedIndex() == 2) refreshDiagnostics(); + }); return tabs; } @@ -254,35 +325,31 @@ private JPanel mappingPanel() { mappingStatus.setBorder(new EmptyBorder(2, 2, 2, 2)); panel.add(mappingStatus, BorderLayout.SOUTH); - return panel; - } - - private JPanel terminalPanel() { - JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(new EmptyBorder(8, 8, 8, 8)); - - JPanel top = new JPanel(new BorderLayout()); - JLabel note = new JLabel("Live player and Workbench activity"); - top.add(note, BorderLayout.WEST); - JButton clear = new JButton("Clear"); - clear.addActionListener(event -> terminalArea.setText("")); - top.add(clear, BorderLayout.EAST); - - panel.add(top, BorderLayout.NORTH); - panel.add(new JScrollPane(terminalArea), BorderLayout.CENTER); + if (mappingView != null) { + JPanel wrap = new JPanel(new BorderLayout()); + wrap.add(mappingView, BorderLayout.CENTER); + wrap.add(mappingStatus, BorderLayout.SOUTH); + return wrap; + } return panel; } private JPanel diagnosticsPanel() { JPanel panel = new JPanel(new BorderLayout()); + panel.setBackground(WorkbenchTheme.SURFACE); + if (diagnosticView != null) { + panel.add(diagnosticView, BorderLayout.CENTER); + return panel; + } panel.setBorder(new EmptyBorder(16, 16, 16, 16)); JTextArea message = outputArea(); message.setText(""" - Diagnostic Log Explorer foundation + Diagnostic Log Explorer - This tab remains intentionally separate from the live scenario player. - It should continue to bind to Pickleball's retained diagnostic artifacts - rather than inventing a second diagnostic store in Workbench. + JavaFX WebView is unavailable in this process, so the explorer + cannot open the timeline UI. Workbench still reads Pickleball's + retained diagnostic artifacts from reports/diagnostic-runs and + does not invent a second store. """); message.setCaretPosition(0); panel.add(new JScrollPane(message), BorderLayout.CENTER); @@ -293,6 +360,7 @@ private JPanel footer() { JPanel footer = new JPanel(new BorderLayout()); activityLabel.setBorder(new EmptyBorder(2, 4, 2, 4)); footer.add(activityLabel, BorderLayout.CENTER); + footer.add(webViewNote, BorderLayout.EAST); return footer; } @@ -336,6 +404,252 @@ public void actionPerformed(java.awt.event.ActionEvent event) { }); } + private void configureWebViews() { + if (!JavaFxSupport.available()) { + webViewNote.setText("JavaFX WebView unavailable: " + JavaFxSupport.failure() + + ". Using the text fallback. OpenJFX is Workbench-only."); + return; + } + try { + gherkinHost.onDocument(this::applyEditorLines); + gherkinHost.onSeek(id -> { + player.clickLine(id); + updateFromHereAvailability(); + refreshPlayheadHighlight(); + }); + gherkinHost.onAddStep(this::insertStep); + gherkinHost.onReady(this::pushGherkinView); + gherkinView = new WebViewPanel( + "/tools/dscode/workbench/ui/web/gherkin-editor.html", + "gherkinHost", + gherkinHost + ); + + mappingHost.onSelect(reference -> { + for (WorkbenchUiController.MappingCatalogEntry entry : mappingEntries) { + if (entry.reference().equals(reference)) { + nodeMapSelector.setSelectedItem(entry); + loadMapping(entry); + return; + } + } + }); + mappingHost.onEdit(this::applyMappingPropertyEdit); + mappingHost.onReady(this::pushMappingView); + mappingView = new WebViewPanel( + "/tools/dscode/workbench/ui/web/mapping-editor.html", + "mappingHost", + mappingHost + ); + + diagnosticHost.onSelectRun(this::showDiagnosticRun); + diagnosticHost.onReady(this::refreshDiagnostics); + diagnosticView = new WebViewPanel( + "/tools/dscode/workbench/ui/web/diagnostic-explorer.html", + "diagnosticHost", + diagnosticHost + ); + } catch (RuntimeException failure) { + gherkinView = null; + mappingView = null; + diagnosticView = null; + webViewNote.setText("JavaFX WebView failed to start: " + failure.getMessage()); + } + } + + private void configurePicker() { + picker.onScenarioSelected(scenario -> { + playback.loadScenario(scenario.lines(), scenario.file()); + picker.setSaveEnabled(true); + syncScenarioView(); + updatePlayerView("Loaded " + scenario.displayLabel() + " into the live session buffer."); + }); + picker.onSave(this::saveLoadedFeature); + } + + private void refreshFeatureCatalog() { + WorkbenchManifest manifest = null; + try { + manifest = WorkbenchManifest.read(controller.projectRoot()); + } catch (RuntimeException ignored) { + // The picker can still scan conventional project feature folders. + } + picker.setCatalog(ConsumerFeatureCatalog.scan(controller.projectRoot(), manifest)); + diagnosticNavigator = new DiagnosticEvidenceNavigator(controller.projectRoot()); + } + + private void togglePicker() { + if (!(pickerSplit instanceof JSplitPane split)) return; + if (pickerToggle.isSelected()) { + split.setDividerLocation(280); + picker.setVisible(true); + } else { + split.setDividerLocation(0); + picker.setVisible(false); + } + split.revalidate(); + } + + private void applyEditorLines(List lines) { + if (syncingScenarioDocument) return; + player.replaceDocument(lines); + updateFromHereAvailability(); + if (player.state() == LiveScenarioPlayer.State.RUNNING) { + if (lastState == null || !lastState.liveReady()) { + prepareLiveSession(this::schedulePlaybackStep); + } else { + schedulePlaybackStep(); + } + } + } + + private void pushGherkinView() { + if (gherkinView == null) return; + gherkinView.evalJsonCall("window.setEditorState", WorkbenchWebJson.editorState(player, executingStepId)); + } + + private void pushMappingView() { + if (mappingView == null) return; + WorkbenchUiController.MappingCatalogEntry selected = + (WorkbenchUiController.MappingCatalogEntry) nodeMapSelector.getSelectedItem(); + mappingView.evalJsonCall( + "window.setMappingState", + WorkbenchWebJson.mappingState( + mappingEntries.stream() + .map(entry -> new WorkbenchWebJson.MapChoice( + entry.reference(), entry.label(), entry.restorable())) + .toList(), + selected == null ? null : new WorkbenchWebJson.MapChoice( + selected.reference(), selected.label(), selected.restorable()), + mappingModel, + mappingStatus.getText() + ) + ); + } + + private void applyMappingPropertyEdit(MappingEditorHost.PropertyEdit edit) { + if (lastState == null || !lastState.liveReady() || loadedMapping == null) return; + try { + if (edit.oldKey() == null || edit.oldKey().isBlank() || edit.oldKey().equals(edit.key())) { + runTask( + () -> controller.mappingPutTyped(edit.mapReference(), edit.key(), edit.type(), edit.text()), + result -> { + terminal.appendExecution("[Mapping] " + edit.key(), result.output(), result.events()); + mappingStatus.setText("Saved " + edit.key() + " through mappingPut."); + if (mappingModel != null) { + mappingModel = mappingModel.upsert( + edit.key(), + MappingValueCodec.parseType(edit.type()), + edit.text() + ); + } + pushMappingView(); + }, + failure -> showFailure("Mapping property edit failed", failure) + ); + return; + } + MappingTreeModel updated = (mappingModel == null + ? new MappingTreeModel(edit.mapReference(), loadedMapping.mapType(), true, Map.of()) + : mappingModel) + .rename(edit.oldKey(), edit.key()) + .upsert(edit.key(), MappingValueCodec.parseType(edit.type()), edit.text()); + runTask( + () -> controller.restoreMapping(loadedMapping, updated.values()), + output -> { + mappingModel = updated; + terminal.appendExecution("[Mapping] " + loadedMapping.mapType(), output, ""); + mappingStatus.setText("Renamed property saved through mappingRestore."); + pushMappingView(); + }, + failure -> showFailure("Mapping restore failed", failure) + ); + } catch (RuntimeException failure) { + mappingStatus.setText(failure.getMessage()); + } + } + + private void saveLoadedFeature() { + Path origin = playback.originFile().orElse(null); + if (origin == null) { + showFailure("Could not save", new IllegalStateException("The default demo is session-only.")); + return; + } + try { + Files.writeString(origin, player.documentText() + System.lineSeparator()); + updatePlayerView("Saved live buffer to " + origin.getFileName() + "."); + } catch (Exception failure) { + showFailure("Could not save feature file", failure); + } + } + + private void refreshDiagnostics() { + if (diagnosticView == null) return; + if (diagnosticNavigator == null) { + diagnosticNavigator = new DiagnosticEvidenceNavigator(controller.projectRoot()); + } + List runs = diagnosticNavigator.catalogRuns(); + if (runs.isEmpty()) { + diagnosticView.evalJsonCall("window.setDiagnosticState", WorkbenchWebJson.write(Map.of( + "runs", List.of(), + "frames", List.of(), + "layers", List.of(), + "gap", diagnosticNavigator.available() + ? "The catalog has no retained runs." + : "No reports/diagnostic-runs/run-catalog.json in this consumer project." + ))); + return; + } + showDiagnosticRun(runs.getFirst().runId()); + } + + private void showDiagnosticRun(String runId) { + if (diagnosticView == null || diagnosticNavigator == null) return; + DiagnosticEvidenceNavigator.CatalogRun selected = diagnosticNavigator.catalogRuns().stream() + .filter(run -> run.runId().equals(runId)) + .findFirst() + .orElse(null); + if (selected == null) return; + DiagnosticEvidenceNavigator.Timeline timeline = diagnosticNavigator.timeline(selected.runRoot()); + List> frames = new ArrayList<>(); + for (DiagnosticEvidenceNavigator.ScreenshotFrame frame : timeline.frames()) { + Map item = new LinkedHashMap<>(); + item.put("stepText", frame.stepText()); + item.put("scenarioId", frame.scenarioId()); + try { + byte[] bytes = Files.readAllBytes(frame.file()); + item.put("dataUri", "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes)); + } catch (Exception ignored) { + continue; + } + frames.add(item); + } + String scenarioId = timeline.frames().isEmpty() ? "" : timeline.frames().getFirst().scenarioId(); + List> layers = new ArrayList<>(); + for (var layer : diagnosticNavigator.layers(selected.runRoot(), scenarioId)) { + layers.add(Map.of( + "layer", layer.layer().name(), + "present", layer.present(), + "excerpt", layer.excerpt() == null ? "" : layer.excerpt() + )); + } + List> runs = new ArrayList<>(); + for (var run : diagnosticNavigator.catalogRuns()) { + runs.add(Map.of( + "runId", run.runId(), + "label", run.runId(), + "selected", run.runId().equals(runId) + )); + } + diagnosticView.evalJsonCall("window.setDiagnosticState", WorkbenchWebJson.write(Map.of( + "runs", runs, + "frames", frames, + "layers", layers, + "index", 0, + "gap", frames.isEmpty() ? "This retained run has no PNG frames." : "" + ))); + } + private void configureMappingEditor() { nodeMapSelector.addActionListener(event -> { if (refreshingCatalog) return; @@ -686,6 +1000,7 @@ private void refreshMappingCatalog() { nodeMapSelector.addItem(entry); if (Objects.equals(previousReference, entry.reference())) selected = entry; } + mappingEntries = List.copyOf(entries); nodeMapSelector.setEnabled(!entries.isEmpty()); if (selected == null && !entries.isEmpty()) selected = entries.getFirst(); if (selected != null) { @@ -715,15 +1030,22 @@ private void loadMapping(WorkbenchUiController.MappingCatalogEntry entry) { () -> controller.mappingSnapshot(entry.reference()), snapshot -> { loadedMapping = snapshot; + mappingModel = new MappingTreeModel( + snapshot.mapReference(), + snapshot.mapType(), + snapshot.restorable(), + snapshot.values() + ); try { String formatted = json.writerWithDefaultPrettyPrinter() .writeValueAsString(snapshot.values()); setMappingEditor(formatted, snapshot.restorable()); mappingStatus.setText( snapshot.restorable() - ? "Live JSON snapshot. Valid edits are applied automatically." + ? "Live NodeMap from the worker ParsingMap. Edits use mappingPut / mappingRestore." : "Inspection only: this NodeMap implementation is not safely restorable." ); + pushMappingView(); } catch (Exception failure) { showFailure("Could not render NodeMap JSON", failure); } @@ -837,6 +1159,7 @@ private void syncScenarioView() { } refreshPlayheadHighlight(); updateFromHereAvailability(); + pushGherkinView(); } private void updateFromHereAvailability() { @@ -914,13 +1237,8 @@ private void updatePlayerView(String activity) { } private void appendTerminal(String heading, String output, String events) { - if (!terminalArea.getText().isEmpty()) terminalArea.append("\n\n"); - terminalArea.append(heading + "\n"); - if (output != null && !output.isBlank()) terminalArea.append(output + "\n"); - if (events != null && !events.isBlank()) { - terminalArea.append("Events\n" + events + "\n"); - } - terminalArea.setCaretPosition(terminalArea.getDocument().getLength()); + terminal.appendExecution(heading, output, events); + controller.workerLogFiles().ifPresent(terminal::setFiles); } private void runStateAction( @@ -933,7 +1251,11 @@ private void runStateAction( state -> { applyState(state); activityLabel.setText(label + " complete."); - if (state.liveReady()) refreshMappingCatalog(); + if (state.liveReady()) { + refreshMappingCatalog(); + controller.workerLogFiles().ifPresent(terminal::setFiles); + } + refreshFeatureCatalog(); }, failure -> showFailure(label + " failed", failure) ); @@ -1217,6 +1539,7 @@ private void closeWorkbench() { closing = true; player.stop(); mappingSaveTimer.stop(); + terminal.stop(); activityLabel.setText("Closing Workbench..."); runTask( () -> { @@ -1241,10 +1564,7 @@ private static JTextArea outputArea() { } private static JButton playerButton(String text, String tooltip) { - JButton button = new JButton(text); - button.setToolTipText(tooltip); - button.setFocusable(false); - return button; + return WorkbenchTheme.flatButton(text, tooltip); } private static JButton smallPlayerButton(String text, String tooltip) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java new file mode 100644 index 00000000..cc55289e --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java @@ -0,0 +1,134 @@ +package tools.dscode.workbench.ui; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSplitPane; +import javax.swing.UIManager; +import javax.swing.border.Border; +import javax.swing.border.EmptyBorder; +import javax.swing.plaf.ColorUIResource; +import javax.swing.plaf.FontUIResource; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Insets; + +/** Flat, readable Swing chrome for the Workbench player. */ +final class WorkbenchTheme { + static final Color BACKGROUND = new Color(0xF4, 0xF6, 0xF8); + static final Color SURFACE = Color.WHITE; + static final Color SURFACE_ALT = new Color(0xEE, 0xF2, 0xF7); + static final Color BORDER = new Color(0xD7, 0xDE, 0xE7); + static final Color TEXT = new Color(0x1F, 0x29, 0x37); + static final Color MUTED = new Color(0x6B, 0x72, 0x80); + static final Color ACCENT = new Color(0x25, 0x63, 0xEB); + static final Color ACCENT_SOFT = new Color(0xDB, 0xEA, 0xFE); + static final Color PLAYHEAD = new Color(0xFE, 0xF3, 0xC7); + static final Color DANGER = new Color(0xB9, 0x1C, 0x1C); + + private WorkbenchTheme() { + } + + static void install() { + UIManager.put("Panel.background", new ColorUIResource(BACKGROUND)); + UIManager.put("OptionPane.background", new ColorUIResource(BACKGROUND)); + UIManager.put("Label.foreground", new ColorUIResource(TEXT)); + UIManager.put("Button.background", new ColorUIResource(SURFACE)); + UIManager.put("Button.foreground", new ColorUIResource(TEXT)); + UIManager.put("Button.focus", new ColorUIResource(ACCENT_SOFT)); + UIManager.put("ToggleButton.background", new ColorUIResource(SURFACE)); + UIManager.put("TextField.background", new ColorUIResource(SURFACE)); + UIManager.put("TextArea.background", new ColorUIResource(SURFACE)); + UIManager.put("ComboBox.background", new ColorUIResource(SURFACE)); + UIManager.put("List.background", new ColorUIResource(SURFACE)); + UIManager.put("TabbedPane.background", new ColorUIResource(BACKGROUND)); + UIManager.put("TabbedPane.contentAreaColor", new ColorUIResource(SURFACE)); + UIManager.put("SplitPane.background", new ColorUIResource(BACKGROUND)); + UIManager.put("MenuBar.background", new ColorUIResource(SURFACE)); + Font base = new Font("SansSerif", Font.PLAIN, 13); + UIManager.put("Label.font", new FontUIResource(base)); + UIManager.put("Button.font", new FontUIResource(base)); + UIManager.put("ToggleButton.font", new FontUIResource(base)); + UIManager.put("TextField.font", new FontUIResource(base)); + UIManager.put("ComboBox.font", new FontUIResource(base)); + UIManager.put("TabbedPane.font", new FontUIResource(base.deriveFont(Font.BOLD, 12f))); + } + + static Border cardBorder() { + return BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + new EmptyBorder(10, 12, 10, 12) + ); + } + + static JPanel card(String title) { + JPanel panel = new JPanel(); + panel.setBackground(SURFACE); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + new EmptyBorder(10, 12, 12, 12) + )); + if (title != null) { + panel.setName(title); + } + return panel; + } + + static JLabel muted(String text) { + JLabel label = new JLabel(text); + label.setForeground(MUTED); + return label; + } + + static JLabel heading(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(Font.BOLD, 13f)); + label.setForeground(TEXT); + return label; + } + + static JButton flatButton(String text, String tooltip) { + JButton button = new JButton(text); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.setBackground(SURFACE); + button.setForeground(TEXT); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + new EmptyBorder(6, 12, 6, 12) + )); + button.setMargin(new Insets(2, 8, 2, 8)); + return button; + } + + static JButton accentButton(String text, String tooltip) { + JButton button = flatButton(text, tooltip); + button.setBackground(ACCENT); + button.setForeground(Color.WHITE); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(ACCENT.darker()), + new EmptyBorder(6, 14, 6, 14) + )); + return button; + } + + static void styleSplit(JSplitPane split) { + split.setBorder(BorderFactory.createEmptyBorder()); + split.setContinuousLayout(true); + split.setDividerSize(8); + split.setBackground(BACKGROUND); + } + + static void surface(JComponent component) { + component.setBackground(SURFACE); + component.setForeground(TEXT); + component.setOpaque(true); + } + + static Dimension compact(int width, int height) { + return new Dimension(width, height); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java index 44cef261..04aa5239 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java @@ -13,6 +13,7 @@ private WorkbenchUi() { public static void launch(Path projectRoot) { Runnable launch = () -> { + WorkbenchTheme.install(); WorkbenchUiController controller = new WorkbenchUiController( projectRoot, new WorkbenchController(projectRoot) diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java index 310b80f4..818e15dc 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java @@ -21,7 +21,9 @@ import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.mapping.MappingValueCodec; import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; import java.nio.file.Path; @@ -30,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; /** Thin presentation adapter over the shared Workbench service surface. */ final class WorkbenchUiController implements AutoCloseable { @@ -227,14 +230,30 @@ LiveActionResult mappingGet(String mapReference, String key) { } LiveActionResult mappingPut(String mapReference, String key, String value) { + return mappingPutValue(mapReference, key, value == null ? "" : value); + } + + LiveActionResult mappingPutTyped(String mapReference, String key, String type, String text) { + return mappingPutValue(mapReference, key, MappingValueCodec.decode(type, text)); + } + + LiveActionResult mappingPutValue(String mapReference, String key, Object value) { ControlBridgeValueResult result = services.mappingPut( required(mapReference, "Mapping reference"), required(key, "Mapping key"), - value == null ? "" : value + value ); return new LiveActionResult(renderValueResult(result), refreshEvents()); } + Path projectRoot() { + return projectRoot; + } + + Optional workerLogFiles() { + return services.workerLogFiles(); + } + LiveActionResult mappingResolve(String input) { ControlBridgeValueResult result = services.mappingResolve(required(input, "Mapping input")); return new LiveActionResult(renderValueResult(result), refreshEvents()); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java new file mode 100644 index 00000000..b8f7de4d --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.ui.web; + +import java.util.function.Consumer; + +/** JavaScript bridge for retained-run navigation. It does not create diagnostic data. */ +public final class DiagnosticExplorerHost { + private Consumer onSelectRun; + private Consumer onFocusLayer; + private Runnable onReady; + + public void onSelectRun(Consumer onSelectRun) { + this.onSelectRun = onSelectRun; + } + + public void onFocusLayer(Consumer onFocusLayer) { + this.onFocusLayer = onFocusLayer; + } + + public void onReady(Runnable onReady) { + this.onReady = onReady; + } + + public void selectRun(String runId) { + WebViewPanel.onSwing(() -> { + if (onSelectRun != null) onSelectRun.accept(runId); + }); + } + + public void focusLayer(String layer) { + WebViewPanel.onSwing(() -> { + if (onFocusLayer != null) onFocusLayer.accept(layer); + }); + } + + public void ready() { + WebViewPanel.onSwing(() -> { + if (onReady != null) onReady.run(); + }); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java new file mode 100644 index 00000000..d091b253 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java @@ -0,0 +1,61 @@ +package tools.dscode.workbench.ui.web; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.LongConsumer; + +/** JavaScript bridge for the live Gherkin block editor. Does not execute Gherkin. */ +public final class GherkinEditorHost { + private Consumer> onDocument; + private LongConsumer onSeek; + private Runnable onAddStep; + private Runnable onReady; + + public void onDocument(Consumer> onDocument) { + this.onDocument = onDocument; + } + + public void onSeek(LongConsumer onSeek) { + this.onSeek = onSeek; + } + + public void onAddStep(Runnable onAddStep) { + this.onAddStep = onAddStep; + } + + public void onReady(Runnable onReady) { + this.onReady = onReady; + } + + public void documentChanged(String json) { + Map payload = WorkbenchWebJson.readMap(json); + Object raw = payload.get("lines"); + List lines = new ArrayList<>(); + if (raw instanceof List list) { + for (Object item : list) lines.add(item == null ? "" : item.toString()); + } + WebViewPanel.onSwing(() -> { + if (onDocument != null) onDocument.accept(lines); + }); + } + + public void seek(long id) { + WebViewPanel.onSwing(() -> { + if (id >= 0 && onSeek != null) onSeek.accept(id); + }); + } + + public void requestAddStep() { + WebViewPanel.onSwing(() -> { + if (onAddStep != null) onAddStep.run(); + }); + } + + public void ready() { + WebViewPanel.onSwing(() -> { + if (onReady != null) onReady.run(); + }); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java new file mode 100644 index 00000000..6864259b --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java @@ -0,0 +1,119 @@ +package tools.dscode.workbench.ui.web; + +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.jar.JarFile; + +/** + * Workbench-only JavaFX bootstrap. + * + *

Investigation chose OpenJFX {@code WebView} + {@code JFXPanel} over JCEF. + * JDK 21 does not ship a modern browser panel. JavaFX WebKit packages as + * Maven-central modules that stay on the Workbench classpath, can be shaded + * into the controller executable, and do not introduce Pickleball core, + * Chromium download caches, or a second Gherkin runtime. JCEF would require + * native Chromium bits that are harder to keep Workbench-only and isolation + * clean.

+ */ +public final class JavaFxSupport { + private static final AtomicBoolean INITIALIZED = new AtomicBoolean(); + private static volatile boolean available; + private static volatile String failure; + + private JavaFxSupport() { + } + + public static synchronized boolean ensureInitialized() { + if (INITIALIZED.get()) return available; + try { + extractNatives(); + if (System.getProperty("prism.order") == null) { + System.setProperty("prism.order", "sw"); + } + Platform.setImplicitExit(false); + new JFXPanel(); + available = true; + failure = null; + } catch (Throwable error) { + available = false; + failure = error.getClass().getSimpleName() + ": " + error.getMessage(); + } + INITIALIZED.set(true); + return available; + } + + public static boolean available() { + return ensureInitialized(); + } + + public static String failure() { + ensureInitialized(); + return failure; + } + + public static void runLater(Runnable action) { + if (!ensureInitialized()) { + throw new IllegalStateException("JavaFX WebView is not available: " + failure); + } + Platform.runLater(action); + } + + static String platformKey() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + boolean arm = arch.contains("aarch64") || arch.contains("arm64"); + if (os.contains("win")) return "win"; + if (os.contains("mac")) return arm ? "mac-aarch64" : "mac"; + return "linux"; + } + + private static void extractNatives() throws IOException { + String platform = platformKey(); + Path cache = Path.of(System.getProperty("java.io.tmpdir"), "pickleball-workbench-javafx", platform); + Files.createDirectories(cache); + URL codeSource = JavaFxSupport.class.getProtectionDomain().getCodeSource() == null + ? null + : JavaFxSupport.class.getProtectionDomain().getCodeSource().getLocation(); + if (codeSource != null && "file".equals(codeSource.getProtocol()) && codeSource.getPath().endsWith(".jar")) { + try { + extractFromJar(new File(codeSource.toURI()).toPath(), "javafx-natives/" + platform + "/", cache); + } catch (Exception ignored) { + // Best effort; OpenJFX may still resolve natives from the shaded JAR root. + } + } + String current = System.getProperty("java.library.path", ""); + if (!current.contains(cache.toString())) { + System.setProperty( + "java.library.path", + cache + System.getProperty("path.separator") + current + ); + } + } + + private static void extractFromJar(Path jarFile, String prefix, Path target) throws IOException { + if (!Files.isRegularFile(jarFile)) return; + try (JarFile jar = new JarFile(jarFile.toFile())) { + jar.stream() + .filter(entry -> !entry.isDirectory() && entry.getName().startsWith(prefix)) + .forEach(entry -> { + String name = Path.of(entry.getName()).getFileName().toString(); + Path out = target.resolve(name); + try (InputStream in = jar.getInputStream(entry)) { + Files.copy(in, out, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException ignored) { + // Native extraction is best-effort. + } + }); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/MappingEditorHost.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/MappingEditorHost.java new file mode 100644 index 00000000..16256584 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/MappingEditorHost.java @@ -0,0 +1,56 @@ +package tools.dscode.workbench.ui.web; + +import java.util.Map; +import java.util.function.Consumer; + +/** JavaScript bridge for Mapping property edits. Persistence stays in WorkbenchServices. */ +public final class MappingEditorHost { + public record PropertyEdit(String mapReference, String oldKey, String key, String type, String text) { } + + private Consumer onSelect; + private Consumer onEdit; + private Runnable onReady; + + public void onSelect(Consumer onSelect) { + this.onSelect = onSelect; + } + + public void onEdit(Consumer onEdit) { + this.onEdit = onEdit; + } + + public void onReady(Runnable onReady) { + this.onReady = onReady; + } + + public void selectMap(String reference) { + WebViewPanel.onSwing(() -> { + if (onSelect != null) onSelect.accept(reference); + }); + } + + public void propertyChanged(String json) { + Map payload = WorkbenchWebJson.readMap(json); + PropertyEdit edit = new PropertyEdit( + string(payload, "mapReference"), + string(payload, "oldKey"), + string(payload, "key"), + string(payload, "type"), + string(payload, "text") + ); + WebViewPanel.onSwing(() -> { + if (onEdit != null) onEdit.accept(edit); + }); + } + + public void ready() { + WebViewPanel.onSwing(() -> { + if (onReady != null) onReady.run(); + }); + } + + private static String string(Map payload, String key) { + Object value = payload.get(key); + return value == null ? "" : value.toString(); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WebViewPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WebViewPanel.java new file mode 100644 index 00000000..13f7c85f --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WebViewPanel.java @@ -0,0 +1,101 @@ +package tools.dscode.workbench.ui.web; + +import javafx.concurrent.Worker; +import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; +import netscape.javascript.JSObject; + +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import java.awt.BorderLayout; +import java.net.URL; +import java.util.Objects; +import java.util.function.Consumer; + +/** Swing host for one Workbench HTML/JS panel running inside JavaFX WebView. */ +public final class WebViewPanel extends JPanel { + private final String resource; + private final String bridgeName; + private final Object bridge; + private final javafx.embed.swing.JFXPanel fxPanel; + private WebEngine engine; + private volatile boolean ready; + + public WebViewPanel(String resource, String bridgeName, Object bridge) { + this.resource = Objects.requireNonNull(resource, "resource"); + this.bridgeName = Objects.requireNonNull(bridgeName, "bridgeName"); + this.bridge = Objects.requireNonNull(bridge, "bridge"); + setLayout(new BorderLayout()); + setOpaque(true); + if (!JavaFxSupport.ensureInitialized()) { + throw new IllegalStateException("JavaFX WebView is not available: " + JavaFxSupport.failure()); + } + fxPanel = new javafx.embed.swing.JFXPanel(); + add(fxPanel, BorderLayout.CENTER); + JavaFxSupport.runLater(this::attachScene); + } + + public boolean ready() { + return ready; + } + + public void eval(String script) { + if (engine == null) return; + JavaFxSupport.runLater(() -> { + try { + engine.executeScript(script); + } catch (RuntimeException ignored) { + // The page may not have finished installing helpers yet. + } + }); + } + + public void evalJsonCall(String functionName, String json) { + String escaped = json + .replace("\\", "\\\\") + .replace("'", "\\'"); + eval(functionName + "('" + escaped + "')"); + } + + private void attachScene() { + WebView view = new WebView(); + view.setContextMenuEnabled(false); + engine = view.getEngine(); + engine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> { + if (newState == Worker.State.SUCCEEDED) { + installBridge(); + ready = true; + } + }); + URL url = WebViewPanel.class.getResource(resource); + if (url == null) { + throw new IllegalStateException("Missing Workbench WebView resource: " + resource); + } + engine.load(url.toExternalForm()); + fxPanel.setScene(new Scene(view, Color.web("#f8fafc"))); + } + + private void installBridge() { + JSObject window = (JSObject) engine.executeScript("window"); + window.setMember(bridgeName, bridge); + try { + engine.executeScript("if (window.onWorkbenchReady) window.onWorkbenchReady();"); + } catch (RuntimeException ignored) { + // Optional page hook. + } + } + + public static void onSwing(Runnable action) { + if (SwingUtilities.isEventDispatchThread()) { + action.run(); + } else { + SwingUtilities.invokeLater(action); + } + } + + public static void onSwing(Consumer action) { + onSwing(() -> action.accept(null)); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java new file mode 100644 index 00000000..342d4efa --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java @@ -0,0 +1,102 @@ +package tools.dscode.workbench.ui.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import tools.dscode.workbench.mapping.MappingTreeModel; +import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.player.GherkinBlockDocument; +import tools.dscode.workbench.player.LiveScenarioPlayer; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class WorkbenchWebJson { + public record MapChoice(String reference, String label, boolean restorable) { } + private static final ObjectMapper JSON = new ObjectMapper(); + + private WorkbenchWebJson() { + } + + public static String editorState(LiveScenarioPlayer player, Long executingId) { + Map payload = new LinkedHashMap<>(); + payload.put("roots", blocks(GherkinBlockDocument.fromPlayer(player).roots())); + payload.put("selectedId", player.selectedId().isPresent() ? player.selectedId().getAsLong() : null); + payload.put("playheadId", player.playheadId().isPresent() ? player.playheadId().getAsLong() : null); + payload.put("executingId", executingId); + return write(payload); + } + + public static String mappingState( + List entries, + MapChoice selected, + MappingTreeModel model, + String status + ) { + Map payload = new LinkedHashMap<>(); + List> maps = new ArrayList<>(); + for (MapChoice entry : entries) { + maps.add(Map.of( + "reference", entry.reference(), + "label", entry.label(), + "restorable", entry.restorable() + )); + } + payload.put("entries", maps); + payload.put("mapReference", selected == null ? "" : selected.reference()); + payload.put("restorable", model != null && model.restorable()); + payload.put("status", status == null ? "" : status); + List> properties = new ArrayList<>(); + if (model != null) { + for (MappingTreeModel.Property property : model.properties()) { + properties.add(Map.of( + "key", property.key(), + "type", jsType(property.type()), + "text", property.text() + )); + } + } + payload.put("properties", properties); + return write(payload); + } + + static String jsType(MappingValueCodec.ValueType type) { + return switch (type) { + case STRING -> "string"; + case NUMERIC -> "numeric"; + case BOOLEAN -> "boolean"; + case OBJECT_JSON -> "object-as-json"; + case OBJECT_XML -> "object-as-xml"; + }; + } + + public static String write(Object value) { + try { + return JSON.writeValueAsString(value); + } catch (Exception failure) { + throw new IllegalStateException("Could not encode Workbench WebView state.", failure); + } + } + + static Map readMap(String json) { + try { + @SuppressWarnings("unchecked") + Map value = JSON.readValue(json, Map.class); + return value; + } catch (Exception failure) { + throw new IllegalArgumentException("Invalid WebView payload.", failure); + } + } + + private static List> blocks(List blocks) { + List> items = new ArrayList<>(); + for (GherkinBlockDocument.Block block : blocks) { + Map item = new LinkedHashMap<>(); + item.put("id", block.id()); + item.put("text", block.text()); + item.put("children", blocks(block.children())); + items.add(item); + } + return items; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java index 20695111..071ec21b 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java @@ -2,11 +2,13 @@ import tools.dscode.control.protocol.*; import tools.dscode.workbench.bridge.ControlBridgeClient; +import tools.dscode.workbench.terminal.WorkerLogFiles; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; /** @@ -38,6 +40,10 @@ public WorkbenchWorkerStatus status() { return workers.status(); } + public Optional workerLogFiles() { + return workers.workerLogFiles(); + } + public WorkbenchWorkerStatus stop() { return workers.stop(); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java index c288305d..bd53a710 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java @@ -8,6 +8,7 @@ import tools.dscode.workbench.bridge.ControlBridgeClient; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; +import tools.dscode.workbench.terminal.WorkerLogFiles; import java.io.File; import java.io.IOException; @@ -21,6 +22,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -150,6 +152,12 @@ synchronized String activeScenarioId() { return requireActive().scenarioId(); } + public synchronized Optional workerLogFiles() { + WorkerSession session = active; + if (session == null) return Optional.empty(); + return Optional.of(new WorkerLogFiles(session.stdout(), session.stderr())); + } + public synchronized WorkbenchWorkerStatus stop() { WorkerSession session = active; active = null; diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.css b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.css new file mode 100644 index 00000000..7800e497 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.css @@ -0,0 +1,60 @@ +html, body { + margin: 0; + height: 100%; + background: #0f172a; + color: #e5e7eb; + font: 13px/1.4 "Segoe UI", "SF Pro Text", sans-serif; +} +.toolbar { + display: flex; + gap: 8px; + align-items: center; + padding: 10px 12px; + background: #111827; + border-bottom: 1px solid #1f2937; +} +select, button { + border: 1px solid #374151; + background: #1f2937; + color: #e5e7eb; + border-radius: 8px; + padding: 5px 8px; +} +.status { color: #93c5fd; } +.stage { + padding: 12px; + text-align: center; +} +#frame { + max-width: 100%; + max-height: 360px; + background: #020617; + border-radius: 10px; +} +.step { + margin-top: 8px; + font: 13px/1.4 ui-monospace, Consolas, monospace; + color: #fde68a; +} +.layers { padding: 0 12px 16px; } +.layer-title { font-weight: 700; margin-bottom: 6px; } +.layer { + display: flex; + justify-content: space-between; + padding: 6px 8px; + margin-bottom: 4px; + border-radius: 8px; + background: #111827; + cursor: pointer; +} +.layer.missing { opacity: 0.45; cursor: default; } +.layer.active { outline: 1px solid #60a5fa; } +#excerpt { + white-space: pre-wrap; + background: #020617; + border-radius: 8px; + padding: 10px; + color: #cbd5e1; + min-height: 80px; +} +.gap { padding: 24px; color: #93c5fd; } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.html b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.html new file mode 100644 index 00000000..eac1c935 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.html @@ -0,0 +1,29 @@ + + + + + Diagnostic Log Explorer + + + +
+ + + + + +
+
+ Retained diagnostic screenshot +
+
+
+
Evidence layers
+
+

+  
+ + + diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js new file mode 100644 index 00000000..6c4aa36a --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js @@ -0,0 +1,81 @@ +(function () { + var runs = document.getElementById("runs"); + var frame = document.getElementById("frame"); + var step = document.getElementById("step"); + var status = document.getElementById("status"); + var layers = document.getElementById("layers"); + var excerpt = document.getElementById("excerpt"); + var model = { runs: [], frames: [], layers: [], index: 0, playing: false, gap: "" }; + var timer = null; + + function render() { + runs.innerHTML = ""; + model.runs.forEach(function (run) { + var option = document.createElement("option"); + option.value = run.runId; + option.textContent = run.label || run.runId; + if (run.selected) option.selected = true; + runs.appendChild(option); + }); + var current = model.frames[model.index]; + if (current && current.dataUri) { + frame.src = current.dataUri; + frame.style.display = "inline-block"; + step.textContent = current.stepText || ""; + } else { + frame.removeAttribute("src"); + frame.style.display = "none"; + step.textContent = model.gap || "No retained screenshot frames for this run."; + } + status.textContent = model.frames.length + ? ("Frame " + (model.index + 1) + " / " + model.frames.length) + : (model.gap || "No retained diagnostic run"); + layers.innerHTML = ""; + (model.layers || []).forEach(function (layer, index) { + var row = document.createElement("div"); + row.className = "layer" + (layer.present ? "" : " missing"); + row.innerHTML = "" + layer.layer + "" + (layer.present ? "available" : "absent") + ""; + if (layer.present) { + row.addEventListener("click", function () { + excerpt.textContent = layer.excerpt || ""; + if (window.diagnosticHost && window.diagnosticHost.focusLayer) { + window.diagnosticHost.focusLayer(layer.layer); + } + }); + } + layers.appendChild(row); + }); + } + + function show(delta) { + if (!model.frames.length) return; + model.index = (model.index + delta + model.frames.length) % model.frames.length; + render(); + } + + document.getElementById("prev").addEventListener("click", function () { show(-1); }); + document.getElementById("next").addEventListener("click", function () { show(1); }); + document.getElementById("play").addEventListener("click", function () { + model.playing = !model.playing; + this.textContent = model.playing ? "Pause" : "Play"; + if (timer) clearInterval(timer); + if (model.playing) { + timer = setInterval(function () { show(1); }, 1200); + } + }); + runs.addEventListener("change", function () { + if (window.diagnosticHost && window.diagnosticHost.selectRun) { + window.diagnosticHost.selectRun(runs.value); + } + }); + + window.setDiagnosticState = function (json) { + model = typeof json === "string" ? JSON.parse(json) : json; + model.index = model.index || 0; + render(); + }; + + window.onWorkbenchReady = function () { + if (window.diagnosticHost && window.diagnosticHost.ready) window.diagnosticHost.ready(); + }; +})(); diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css new file mode 100644 index 00000000..099ad28c --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css @@ -0,0 +1,92 @@ +:root { + --bg: #f8fafc; + --ink: #111827; + --muted: #6b7280; + --line: #dbe2ea; + --given: #1d4ed8; + --when: #b45309; + --then: #047857; + --and: #334155; + --iff: #be123c; + --play: #fde68a; + --exec: #bfdbfe; + --card: #ffffff; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--ink); + font: 13px/1.45 "Segoe UI", "SF Pro Text", sans-serif; +} +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--line); + background: #fff; +} +.hint { color: var(--muted); } +button { + border: 1px solid var(--line); + background: #fff; + border-radius: 8px; + padding: 5px 10px; + cursor: pointer; +} +.board { + padding: 12px 14px 28px; + min-height: calc(100% - 42px); +} +.block { + background: var(--card); + border: 1px solid var(--line); + border-left: 4px solid var(--and); + border-radius: 10px; + box-shadow: var(--shadow); + margin: 6px 0; + padding: 6px 8px 6px 10px; +} +.block.given { border-left-color: var(--given); } +.block.when { border-left-color: var(--when); } +.block.then { border-left-color: var(--then); } +.block.iff { border-left-color: var(--iff); } +.block.structure { + background: #eef2ff; + border-left-color: #4f46e5; +} +.block.playhead { background: var(--play); } +.block.executing { background: var(--exec); } +.block.drop-target { outline: 2px dashed #2563eb; } +.row { + display: flex; + align-items: flex-start; + gap: 8px; +} +.handle { + cursor: grab; + color: var(--muted); + user-select: none; + padding-top: 4px; +} +.gherkin { + flex: 1; + border: 0; + resize: none; + background: transparent; + font: 13px/1.45 ui-monospace, "Cascadia Mono", Consolas, monospace; + color: inherit; + outline: none; + min-height: 22px; + width: 100%; +} +.children { + margin: 6px 0 2px 22px; + min-height: 8px; + padding-left: 8px; + border-left: 2px solid #e5e7eb; +} +.ghost { opacity: 0.45; } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html new file mode 100644 index 00000000..01911cc8 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html @@ -0,0 +1,16 @@ + + + + + Live Gherkin Editor + + + +
+ Drag blocks to reorder. Drop on a step to nest. Text stays Gherkin. + +
+
+ + + diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js new file mode 100644 index 00000000..c5aace2d --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js @@ -0,0 +1,228 @@ +(function () { + var board = document.getElementById("board"); + var state = { roots: [], selectedId: null, playheadId: null, executingId: null, suppress: false }; + var dragging = null; + + function classify(text) { + var trimmed = String(text || "").replace(/^[\s:]+/, ""); + if (/^(Feature|Rule|Background|Scenario|Scenario Outline|Examples):/.test(trimmed)) return "structure"; + if (/^Given\b/.test(trimmed)) return "given"; + if (/^When\b/.test(trimmed)) return "when"; + if (/^Then\b/.test(trimmed)) return "then"; + if (/^(IF|ELSE|ELSE-IF)\b/.test(trimmed)) return "iff"; + return "and"; + } + + function autosize(area) { + area.style.height = "0px"; + area.style.height = Math.max(22, area.scrollHeight) + "px"; + } + + function render() { + board.innerHTML = ""; + state.roots.forEach(function (block) { + board.appendChild(renderBlock(block)); + }); + } + + function renderBlock(block) { + var card = document.createElement("div"); + card.className = "block " + classify(block.text); + card.dataset.id = String(block.id); + if (block.id === state.playheadId) card.classList.add("playhead"); + if (block.id === state.executingId) card.classList.add("executing"); + card.draggable = true; + + var row = document.createElement("div"); + row.className = "row"; + var handle = document.createElement("div"); + handle.className = "handle"; + handle.textContent = "⋮⋮"; + var input = document.createElement("textarea"); + input.className = "gherkin"; + input.value = block.text; + input.spellcheck = false; + input.addEventListener("input", function () { + block.text = input.value; + autosize(input); + report(); + }); + input.addEventListener("focus", function () { + state.selectedId = block.id; + reportSeek(); + }); + row.appendChild(handle); + row.appendChild(input); + card.appendChild(row); + + var children = document.createElement("div"); + children.className = "children"; + (block.children || []).forEach(function (child) { + children.appendChild(renderBlock(child)); + }); + card.appendChild(children); + + card.addEventListener("click", function (event) { + if (event.target === input) return; + state.selectedId = block.id; + reportSeek(); + }); + + card.addEventListener("dragstart", function (event) { + dragging = { id: block.id }; + card.classList.add("ghost"); + event.dataTransfer.setData("text/plain", String(block.id)); + event.dataTransfer.effectAllowed = "move"; + }); + card.addEventListener("dragend", function () { + card.classList.remove("ghost"); + dragging = null; + clearDrop(); + }); + card.addEventListener("dragover", function (event) { + event.preventDefault(); + event.stopPropagation(); + card.classList.add("drop-target"); + }); + card.addEventListener("dragleave", function () { + card.classList.remove("drop-target"); + }); + card.addEventListener("drop", function (event) { + event.preventDefault(); + event.stopPropagation(); + card.classList.remove("drop-target"); + var sourceId = Number(event.dataTransfer.getData("text/plain")); + if (!sourceId || sourceId === block.id) return; + var rect = card.getBoundingClientRect(); + var nested = event.clientX > rect.left + 48 && !isStructure(block.text); + moveBlock(sourceId, nested ? block.id : parentId(block.id), insertIndex(block.id, nested, event.clientY, rect)); + }); + setTimeout(function () { autosize(input); }, 0); + return card; + } + + function isStructure(text) { + return classify(text) === "structure"; + } + + function parentId(id) { + var found = findParent(state.roots, id, null); + return found; + } + + function findParent(blocks, id, parent) { + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].id === id) return parent; + var nested = findParent(blocks[i].children || [], id, blocks[i].id); + if (nested !== undefined && nested !== null || (nested === null && findBlock(blocks[i].children || [], id))) { + if (findBlock(blocks[i].children || [], id)) return blocks[i].id; + return nested; + } + } + return null; + } + + function findBlock(blocks, id) { + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].id === id) return blocks[i]; + var nested = findBlock(blocks[i].children || [], id); + if (nested) return nested; + } + return null; + } + + function insertIndex(targetId, nested, clientY, rect) { + if (nested) { + var target = findBlock(state.roots, targetId); + return target && target.children ? target.children.length : 0; + } + return clientY > (rect.top + rect.height / 2) ? indexAmongSiblings(targetId) + 1 : indexAmongSiblings(targetId); + } + + function indexAmongSiblings(id) { + var parent = parentId(id); + var siblings = parent == null ? state.roots : (findBlock(state.roots, parent).children || []); + for (var i = 0; i < siblings.length; i++) { + if (siblings[i].id === id) return i; + } + return siblings.length; + } + + function moveBlock(sourceId, newParentId, index) { + var removed = removeBlock(state.roots, sourceId); + if (!removed) return; + if (newParentId == null) { + state.roots.splice(Math.max(0, Math.min(index, state.roots.length)), 0, removed); + } else { + var parent = findBlock(state.roots, newParentId); + if (!parent || contains(removed, newParentId)) { + state.roots.push(removed); + } else { + parent.children = parent.children || []; + parent.children.splice(Math.max(0, Math.min(index, parent.children.length)), 0, removed); + } + } + render(); + report(); + } + + function contains(block, id) { + if (block.id === id) return true; + return (block.children || []).some(function (child) { return contains(child, id); }); + } + + function removeBlock(blocks, id) { + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].id === id) return blocks.splice(i, 1)[0]; + var nested = removeBlock(blocks[i].children || [], id); + if (nested) return nested; + } + return null; + } + + function flatten(blocks, level, out) { + (blocks || []).forEach(function (block) { + var body = String(block.text || "").replace(/^[\s:]+/, ""); + var line = level > 0 ? (" " + ":".repeat(level) + " " + body) : (block.text || ""); + out.push({ id: block.id, text: line }); + flatten(block.children || [], level + 1, out); + }); + } + + function report() { + if (state.suppress || !window.gherkinHost || !window.gherkinHost.documentChanged) return; + var lines = []; + flatten(state.roots, 0, lines); + window.gherkinHost.documentChanged(JSON.stringify({ + lines: lines.map(function (item) { return item.text; }), + selectedId: state.selectedId, + playheadId: state.playheadId + })); + } + + function reportSeek() { + if (state.suppress || !window.gherkinHost || !window.gherkinHost.seek) return; + window.gherkinHost.seek(state.selectedId == null ? -1 : state.selectedId); + } + + window.setEditorState = function (json) { + var payload = typeof json === "string" ? JSON.parse(json) : json; + state.suppress = true; + state.roots = payload.roots || []; + state.selectedId = payload.selectedId; + state.playheadId = payload.playheadId; + state.executingId = payload.executingId; + render(); + state.suppress = false; + }; + + document.getElementById("add-block").addEventListener("click", function () { + if (window.gherkinHost && window.gherkinHost.requestAddStep) { + window.gherkinHost.requestAddStep(); + } + }); + + window.onWorkbenchReady = function () { + if (window.gherkinHost && window.gherkinHost.ready) window.gherkinHost.ready(); + }; +})(); diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css new file mode 100644 index 00000000..16fffec9 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css @@ -0,0 +1,36 @@ +html, body { + margin: 0; + height: 100%; + background: #f8fafc; + color: #111827; + font: 13px/1.4 "Segoe UI", "SF Pro Text", sans-serif; +} +.toolbar { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid #dbe2ea; + background: #fff; +} +select, input, textarea, button { + border: 1px solid #d7dee7; + border-radius: 8px; + padding: 5px 8px; + font: inherit; +} +.status { color: #6b7280; } +.tree { padding: 12px; } +.row { + display: grid; + grid-template-columns: minmax(120px, 1fr) 140px minmax(160px, 2fr); + gap: 8px; + margin-bottom: 8px; + align-items: start; +} +textarea { + min-height: 34px; + font: 12px/1.4 ui-monospace, Consolas, monospace; + width: 100%; +} +.empty { color: #6b7280; padding: 18px; } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html new file mode 100644 index 00000000..bec91f2e --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html @@ -0,0 +1,19 @@ + + + + + Mapping + + + +
+ + + Start the live worker to inspect Mapping. +
+
+ + + diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js new file mode 100644 index 00000000..3bee9d02 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js @@ -0,0 +1,106 @@ +(function () { + var maps = document.getElementById("maps"); + var tree = document.getElementById("tree"); + var status = document.getElementById("status"); + var model = { entries: [], properties: [], restorable: false, mapReference: "" }; + + function render() { + maps.innerHTML = ""; + model.entries.forEach(function (entry, index) { + var option = document.createElement("option"); + option.value = entry.reference; + option.textContent = entry.label; + if (entry.reference === model.mapReference) option.selected = true; + maps.appendChild(option); + if (!model.mapReference && index === 0) option.selected = true; + }); + tree.innerHTML = ""; + if (!model.properties.length) { + var empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = model.entries.length + ? "This NodeMap has no properties yet." + : "No NodeMaps are available in the current ParsingMap."; + tree.appendChild(empty); + return; + } + model.properties.forEach(function (property) { + tree.appendChild(row(property)); + }); + } + + function row(property) { + var wrap = document.createElement("div"); + wrap.className = "row"; + var key = document.createElement("input"); + key.value = property.key; + key.disabled = !model.restorable; + var type = document.createElement("select"); + ["string", "numeric", "boolean", "object-as-json", "object-as-xml"].forEach(function (name) { + var option = document.createElement("option"); + option.value = name; + option.textContent = name; + if (name === property.type) option.selected = true; + type.appendChild(option); + }); + type.disabled = !model.restorable; + var value = document.createElement("textarea"); + value.value = property.text; + value.disabled = !model.restorable; + function commit() { + if (!window.mappingHost || !window.mappingHost.propertyChanged) return; + window.mappingHost.propertyChanged(JSON.stringify({ + mapReference: model.mapReference, + oldKey: property.key, + key: key.value, + type: type.value, + text: value.value + })); + property.key = key.value; + property.type = type.value; + property.text = value.value; + } + key.addEventListener("change", commit); + type.addEventListener("change", commit); + value.addEventListener("change", commit); + wrap.appendChild(key); + wrap.appendChild(type); + wrap.appendChild(value); + return wrap; + } + + maps.addEventListener("change", function () { + if (window.mappingHost && window.mappingHost.selectMap) { + window.mappingHost.selectMap(maps.value); + } + }); + document.getElementById("add").addEventListener("click", function () { + if (!model.restorable) return; + var key = "newProperty"; + var n = 1; + while (model.properties.some(function (item) { return item.key === key; })) { + key = "newProperty" + (++n); + } + model.properties.push({ key: key, type: "string", text: "" }); + render(); + if (window.mappingHost && window.mappingHost.propertyChanged) { + window.mappingHost.propertyChanged(JSON.stringify({ + mapReference: model.mapReference, + oldKey: "", + key: key, + type: "string", + text: "" + })); + } + }); + + window.setMappingState = function (json) { + model = typeof json === "string" ? JSON.parse(json) : json; + status.textContent = model.status || ""; + render(); + }; + + window.onWorkbenchReady = function () { + if (window.mappingHost && window.mappingHost.ready) window.mappingHost.ready(); + }; +})(); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java new file mode 100644 index 00000000..996485e0 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java @@ -0,0 +1,105 @@ +package tools.dscode.workbench.catalog; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.workbench.sync.WorkbenchManifest; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConsumerFeatureCatalogTest { + @TempDir + Path project; + + @Test + void discoversFeaturesFromProjectOwnedTestResourcesAndSupportsSelectionSearch() throws Exception { + Path features = project.resolve("src/test/resources/features"); + Files.createDirectories(features.resolve("workflow")); + Files.writeString(features.resolve("login.feature"), """ + Feature: Sign in + Scenario: Valid password + Given a user + Scenario: Locked account + Given a lock + """); + Files.writeString(features.resolve("workflow/nested.feature"), """ + Feature: Nested workflow + Scenario: IF ELSE branch + * IF: true + : * Then stay + """); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, null); + assertEquals(2, catalog.features().size()); + assertEquals("Nested workflow", catalog.featuresForBrowse().stream() + .filter(feature -> feature.featureName().equals("Nested workflow")) + .findFirst() + .orElseThrow() + .featureName()); + + catalog.setBrowseMode(ConsumerFeatureCatalog.BrowseMode.FILE_PATH); + assertTrue(catalog.featuresForBrowse().stream() + .anyMatch(feature -> feature.browseLabel(catalog.browseMode()).contains("workflow/nested.feature"))); + + assertEquals(3, catalog.visibleScenarios().size()); + + catalog.toggleFeature(features.resolve("login.feature")); + assertEquals(2, catalog.visibleScenarios().size()); + assertTrue(catalog.visibleScenarios().stream().allMatch(scenario -> scenario.featureName().equals("Sign in"))); + + catalog.setScenarioQuery("locked"); + assertEquals(1, catalog.visibleScenarios().size()); + assertEquals("Locked account", catalog.visibleScenarios().getFirst().name()); + + catalog.clearFeatureSelection(); + catalog.setScenarioQuery("if else"); + assertEquals(1, catalog.visibleScenarios().size()); + assertEquals("IF ELSE branch", catalog.visibleScenarios().getFirst().name()); + assertTrue(catalog.visibleScenarios().getFirst().lines().getFirst().startsWith("Feature:")); + } + + @Test + void prefersClasspathFeaturesConfigurationWithoutCrawlingUnrelatedDirectories() throws Exception { + Files.createDirectories(project.resolve("src/test/resources/features")); + Files.writeString(project.resolve("src/test/resources/features/configured.feature"), """ + Feature: Configured + Scenario: Only this + Given stay in project features + """); + Files.createDirectories(project.resolve("unrelated/features")); + Files.writeString(project.resolve("unrelated/features/outside.feature"), """ + Feature: Outside + Scenario: Must not appear + Given ignored + """); + Files.createDirectories(project.resolve("src/test/resources")); + Files.writeString(project.resolve("src/test/resources/pickleball.properties"), + "pkb_features=classpath:features\n"); + + WorkbenchManifest manifest = new WorkbenchManifest( + WorkbenchManifest.CURRENT_SCHEMA, + project.toString(), + "MAVEN", + "mvn", + List.of(project.resolve("src/test/resources").toString()), + List.of(), + List.of(), + project.resolve("live").toString(), + "2026-01-01T00:00:00Z", + "fp", + List.of(), + "2.1.9", + "21", + "/usr/lib/jvm" + ); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, manifest); + assertEquals(1, catalog.features().size()); + assertEquals("Configured", catalog.features().getFirst().featureName()); + assertEquals("Only this", catalog.visibleScenarios().getFirst().name()); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java new file mode 100644 index 00000000..81ae6c50 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java @@ -0,0 +1,51 @@ +package tools.dscode.workbench.diagnostics; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DiagnosticEvidenceNavigatorTest { + @TempDir + Path project; + + @Test + void readsCatalogThenIndexAndScreenshotFramesWithoutInventingRuns() throws Exception { + Path root = project.resolve("reports/diagnostic-runs"); + Path run = root.resolve("run-1"); + Path scenario = run.resolve("scenarios/scenario-1"); + Path shots = scenario.resolve("screenshots"); + Files.createDirectories(shots); + Files.writeString(root.resolve("run-catalog.json"), """ + {"runs":[{"runId":"run-1","outcome":"PASSED"}]} + """); + Files.writeString(run.resolve("run-index.json"), """ + {"runId":"run-1","outcome":"PASSED"} + """); + Files.writeString(scenario.resolve("summary.json"), """ + {"lastStepText":"Then stay"} + """); + Files.writeString(scenario.resolve("events.jsonl"), """ + {"stepText":"Given navigate to: URL.home"} + {"stepText":"Then stay"} + """); + Path png = shots.resolve("frame-1.png"); + Files.write(png, new byte[]{1, 2, 3}); + + DiagnosticEvidenceNavigator navigator = new DiagnosticEvidenceNavigator(project); + assertTrue(navigator.available()); + assertEquals("run-1", navigator.catalogRuns().getFirst().runId()); + + DiagnosticEvidenceNavigator.Timeline timeline = navigator.timeline(run); + assertEquals(1, timeline.frames().size()); + assertEquals("Given navigate to: URL.home", timeline.frames().getFirst().stepText()); + + assertTrue(navigator.layers(run, "scenario-1").stream() + .anyMatch(layer -> layer.layer() == DiagnosticEvidenceNavigator.Layer.EVENTS && layer.present())); + assertTrue(new DiagnosticEvidenceNavigator(project, project.resolve("missing")).catalogRuns().isEmpty()); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java new file mode 100644 index 00000000..32b5e027 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java @@ -0,0 +1,57 @@ +package tools.dscode.workbench.mapping; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MappingValueCodecTest { + @Test + void decodesEachSupportedMappingValueType() { + assertEquals("plain", MappingValueCodec.decode("string", "plain")); + assertEquals(42L, MappingValueCodec.decode("numeric", "42")); + assertEquals(1.5d, MappingValueCodec.decode("numeric", "1.5")); + assertEquals(Boolean.TRUE, MappingValueCodec.decode("boolean", "true")); + Object json = MappingValueCodec.decode("object-as-json", "{\"city\":\"Austin\"}"); + assertEquals("Austin", ((Map) json).get("city")); + Object xml = MappingValueCodec.decode("object-as-xml", "Austin"); + assertInstanceOf(Map.class, xml); + assertEquals("city", ((Map) xml).get("_name")); + assertEquals("Austin", ((Map) xml).get("_text")); + } + + @Test + void treeEditsPreserveTypedValuesForRestore() { + MappingTreeModel model = new MappingTreeModel( + "OVERRIDE", + "OVERRIDE", + true, + Map.of("count", 1) + ); + MappingTreeModel edited = model + .upsert("flag", MappingValueCodec.ValueType.BOOLEAN, "true") + .upsert("payload", MappingValueCodec.ValueType.OBJECT_JSON, "{\"ok\":true}") + .rename("count", "total"); + + assertEquals(Boolean.TRUE, edited.values().get("flag")); + assertEquals(1, edited.values().get("total")); + assertEquals(true, ((Map) edited.values().get("payload")).get("ok")); + assertTrue(edited.properties().stream().anyMatch(property -> + property.key().equals("flag") && property.type() == MappingValueCodec.ValueType.BOOLEAN)); + } + + @Test + void rejectsBlankKeysAndInvalidBooleanText() { + MappingTreeModel model = new MappingTreeModel("OVERRIDE", "OVERRIDE", true, Map.of()); + assertThrows(IllegalArgumentException.class, () -> + model.upsert(" ", MappingValueCodec.ValueType.STRING, "x")); + assertThrows(IllegalArgumentException.class, () -> + MappingValueCodec.decode(MappingValueCodec.ValueType.BOOLEAN, "yes")); + assertEquals(List.of(), MappingValueCodec.decode(MappingValueCodec.ValueType.OBJECT_JSON, "[]")); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java new file mode 100644 index 00000000..7245c700 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java @@ -0,0 +1,70 @@ +package tools.dscode.workbench.player; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.OptionalLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GherkinBlockDocumentTest { + @Test + void nestedColonStepsBecomeParentChildBlocksAndRoundTripToGherkin() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Feature: Nested", + "Scenario: IF ELSE", + " * IF: the field is visible:", + " : * Then , click save", + " * ELSE:", + " : * Then , click cancel" + )); + + GherkinBlockDocument document = GherkinBlockDocument.fromPlayer(player); + assertEquals(4, document.roots().size()); + GherkinBlockDocument.Block ifBlock = document.roots().get(2); + assertEquals("* IF: the field is visible:", ifBlock.text().strip()); + assertEquals(1, ifBlock.children().size()); + assertEquals("* Then , click save", ifBlock.children().getFirst().text().strip()); + assertTrue(ifBlock.children().getFirst().text().contains("Then")); + + LiveScenarioPlayer copy = LiveScenarioPlayer.interactiveBuffer(); + document.applyTo(copy); + assertTrue(copy.documentText().contains(": * Then , click save")); + assertTrue(copy.documentText().contains("Then")); + } + + @Test + void movingAStepNestsItUnderIfElseAndUpdatesThePlayerBuffer() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given parent stays", + "And child moves", + "* IF: ready:" + )); + GherkinBlockDocument document = GherkinBlockDocument.fromPlayer(player); + long child = document.roots().get(1).id(); + long ifId = document.roots().get(2).id(); + + GherkinBlockDocument nested = document.move(child, OptionalLong.of(ifId), 0); + assertEquals(2, nested.roots().size()); + assertEquals(1, nested.find(ifId).orElseThrow().children().size()); + LivePlaybackCoordinator coordinator = new LivePlaybackCoordinator(player); + coordinator.replaceFromBlocks(nested); + + assertTrue(player.documentText().contains(": And child moves")); + assertTrue(player.documentText().contains("* IF: ready:")); + assertTrue(player.lines().stream().anyMatch(line -> line.text().contains("And child moves"))); + assertTrue(GherkinBlockDocument.fromPlayer(player).roots().stream() + .anyMatch(block -> block.text().contains("IF:") && block.children().size() == 1)); + } + + @Test + void editingBlockTextKeepsGivenWhenThen() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of(" Given navigate to: URL.home")); + GherkinBlockDocument document = GherkinBlockDocument.fromPlayer(player); + long id = document.roots().getFirst().id(); + document.updateText(id, " When , click the \"Open Forms Playground\" Link").applyTo(player); + assertEquals(" When , click the \"Open Forms Playground\" Link", player.lines().getFirst().text()); + assertEquals(LiveScenarioPlayer.LineType.STEP, player.lines().getFirst().type()); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java new file mode 100644 index 00000000..7eb718af --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java @@ -0,0 +1,66 @@ +package tools.dscode.workbench.player; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LivePlaybackCoordinatorTest { + @Test + void playHeaderStillStartsFromBeginningStepsFromHereAndWaitsAtEnd() { + LivePlaybackCoordinator coordinator = new LivePlaybackCoordinator(new LiveScenarioPlayer(List.of( + "Given first", + "And second", + "Then third" + ))); + LiveScenarioPlayer player = coordinator.player(); + coordinator.seek(player.lines().get(2).id()); + + coordinator.playFromStart(); + assertEquals("Given first", player.nextStep().orElseThrow().text()); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + assertEquals("Then third", player.nextStep().orElseThrow().text()); + + coordinator.playFromStart(); + assertEquals("Given first", player.nextStep().orElseThrow().text()); + + coordinator.seek(player.lines().get(1).id()); + coordinator.playFromHere(); + assertEquals("And second", player.nextStep().orElseThrow().text()); + + coordinator.stepOnly(); + assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + + player.startFromBeginning(); + while (player.nextStep().isPresent()) { + player.markCurrentStepExecuted(player.nextStep().orElseThrow().id()); + } + assertTrue(coordinator.waitingForStep()); + coordinator.insertAndMaybeContinue("And appended"); + assertTrue(coordinator.running()); + assertEquals("And appended", player.nextStep().orElseThrow().text()); + } + + @Test + void loadingAScenarioReplacesTheLiveBufferWithoutWritingFiles() { + LivePlaybackCoordinator coordinator = new LivePlaybackCoordinator(LiveScenarioPlayer.interactiveBuffer()); + coordinator.loadScenario(List.of( + "Feature: From picker", + "Scenario: Loaded", + " Given stay in session" + ), null); + + assertEquals("Feature: From picker\nScenario: Loaded\n Given stay in session", + coordinator.player().documentText()); + assertEquals(LiveScenarioPlayer.State.STOPPED, coordinator.player().state()); + assertTrue(coordinator.originFile().isEmpty()); + + coordinator.loadDefaultDemo(); + assertTrue(coordinator.player().documentText().contains("navigate to: URL.home")); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java index cf5a1478..03f2f162 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java @@ -12,6 +12,16 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class LiveScenarioPlayerTest { + @Test + void loadDocumentReplacesTheSessionBufferAndStopsPlayback() { + LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + player.startFromBeginning(); + player.loadDocument(List.of("Feature: Picker", " Given stay in session")); + assertEquals(LiveScenarioPlayer.State.STOPPED, player.state()); + assertEquals("Feature: Picker\n Given stay in session", player.documentText()); + assertTrue(player.lines().get(1).executable()); + } + @Test void defaultDemoScenarioIsNonEmptyAndIncludesBrowserInteraction() { List demo = LiveScenarioPlayer.DEFAULT_DEMO_SCENARIO; diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java new file mode 100644 index 00000000..42be6fcd --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java @@ -0,0 +1,30 @@ +package tools.dscode.workbench.terminal; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WorkerLogBufferTest { + @Test + void filtersParsedLevelsAndTreatsUnmarkedScenarioOutputAsInfo() { + WorkerLogBuffer buffer = new WorkerLogBuffer(); + buffer.appendRaw(""" + [TRACE] hidden + [DEBUG] detail + [INFO] Given navigate to: URL.home + [WARNING] retry + [ERROR] failed + Status: SUCCESS + """); + + buffer.setMinimum(WorkerLogBuffer.Level.WARNING); + assertEquals(2, buffer.visible().size()); + assertEquals(WorkerLogBuffer.Level.WARNING, buffer.visible().getFirst().level()); + assertEquals(WorkerLogBuffer.Level.ERROR, buffer.visible().get(1).level()); + + buffer.setMinimum(WorkerLogBuffer.Level.INFO); + assertEquals(4, buffer.visible().size()); + assertEquals(WorkerLogBuffer.Level.INFO, buffer.visible().getFirst().level()); + assertEquals("Status: SUCCESS", buffer.visible().get(3).raw()); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java index ce051af1..ce251b42 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java @@ -146,6 +146,42 @@ void liveGherkinAndMappingDelegateToSharedWorkbenchServicesAndRefreshEvents() { ), recording.calls); } + @Test + void typedMappingEditsGoThroughSharedWorkbenchServices() { + RecordingServices recording = new RecordingServices(); + WorkbenchUiController controller = new WorkbenchUiController( + Path.of("consumer"), + recording.services() + ); + + WorkbenchUiController.LiveActionResult numeric = controller.mappingPutTyped( + "OVERRIDE", + "count", + "numeric", + "42" + ); + WorkbenchUiController.LiveActionResult flag = controller.mappingPutTyped( + "OVERRIDE", + "ready", + "boolean", + "true" + ); + WorkbenchUiController.LiveActionResult json = controller.mappingPutTyped( + "OVERRIDE", + "payload", + "object-as-json", + "{\"city\":\"Austin\"}" + ); + + assertTrue(numeric.output().contains("Status: SUCCESS")); + assertTrue(flag.output().contains("Status: SUCCESS")); + assertTrue(json.output().contains("Status: SUCCESS")); + assertEquals("mappingPut:OVERRIDE:count:42", recording.calls.get(0)); + assertEquals("mappingPut:OVERRIDE:ready:true", recording.calls.get(2)); + assertTrue(recording.calls.get(4).startsWith("mappingPut:OVERRIDE:payload:")); + assertTrue(recording.calls.get(4).contains("Austin")); + } + @Test void stepOverrideEvidenceAndBreakpointActionsDelegateToSharedWorkbenchServices() { RecordingServices recording = new RecordingServices(); @@ -490,6 +526,11 @@ yield new ControlBridgeServiceCallResult( breakpoints.clear(); yield count; } + case "projectRoot" -> Path.of("consumer"); + case "workerLogFiles" -> { + calls.add("workerLogFiles"); + yield java.util.Optional.empty(); + } case "close" -> { calls.add("close"); yield null; diff --git a/src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java b/src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java index 82afac0e..b4703c34 100644 --- a/src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java +++ b/src/main/java/tools/dscode/launcher/PickleballWorkbenchLauncher.java @@ -2,12 +2,15 @@ import tools.dscode.control.protocol.ControlProtocol; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; @@ -20,7 +23,12 @@ * consumer's Pickleball dependency and always launches it in a separate JVM. */ public final class PickleballWorkbenchLauncher { - private static final int MAX_PAYLOAD_BYTES = 100 * 1024 * 1024; + /** + * Safety cap for the opaque controller JAR. OpenJFX WebView natives make + * the payload larger than a plain Java executable, so this is a stream + * limit rather than an in-memory buffer size. + */ + static final long MAX_PAYLOAD_BYTES = 512L * 1024 * 1024; private static final Set PROJECT_COMMANDS = Set.of( "sync", "status", "worker-check", "live-check", "ui", "mcp" ); @@ -32,7 +40,7 @@ public static void main(String[] args) { String[] forwarded = normalizedArguments(args); Path project = projectRoot(forwarded); try { - Path controllerJar = extractPayload(project, readEmbeddedPayload()); + Path controllerJar = extractEmbeddedPayload(project); Process process = new ProcessBuilder(command(controllerJar, forwarded)) .directory(project.toFile()) .inheritIO() @@ -49,7 +57,7 @@ public static void main(String[] args) { } } - static byte[] readEmbeddedPayload() { + static Path extractEmbeddedPayload(Path projectRoot) throws IOException { ClassLoader loader = PickleballWorkbenchLauncher.class.getClassLoader(); try (InputStream input = loader.getResourceAsStream(ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE)) { if (input == null) { @@ -57,13 +65,7 @@ static byte[] readEmbeddedPayload() { "Pickleball artifact is missing " + ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE ); } - byte[] payload = input.readNBytes(MAX_PAYLOAD_BYTES + 1); - if (payload.length > MAX_PAYLOAD_BYTES) { - throw new IllegalStateException("Embedded Workbench payload exceeds the safety limit."); - } - return payload; - } catch (IOException failure) { - throw new IllegalStateException("Could not read the embedded Workbench payload.", failure); + return extractPayload(projectRoot, input); } } @@ -71,27 +73,40 @@ static Path extractPayload(Path projectRoot, byte[] payload) throws IOException if (payload == null || payload.length == 0) { throw new IllegalArgumentException("Workbench payload must not be empty."); } + if (payload.length > MAX_PAYLOAD_BYTES) { + throw new IllegalStateException("Embedded Workbench payload exceeds the safety limit."); + } + return extractPayload(projectRoot, new ByteArrayInputStream(payload)); + } + + static Path extractPayload(Path projectRoot, InputStream payload) throws IOException { + if (payload == null) { + throw new IllegalArgumentException("Workbench payload must not be empty."); + } Path project = projectRoot.toAbsolutePath().normalize(); if (!Files.isDirectory(project)) { throw new IllegalArgumentException( "Consumer project directory does not exist: " + project ); } - String checksum = sha256(payload); - Path directory = project.resolve(".pickleball") + Path staging = project.resolve(".pickleball") .resolve("workbench") - .resolve("controller") - .resolve(checksum); - Path target = directory.resolve("pickleball-workbench.jar"); - Files.createDirectories(directory); + .resolve("controller"); + Files.createDirectories(staging); + Path temporary = Files.createTempFile(staging, "pickleball-workbench-", ".tmp"); + try { + HashedCopy copy = copyAndHash(payload, temporary); + if (copy.bytes == 0) { + throw new IllegalArgumentException("Workbench payload must not be empty."); + } + Path directory = staging.resolve(copy.checksum); + Path target = directory.resolve("pickleball-workbench.jar"); + Files.createDirectories(directory); - if (Files.isRegularFile(target) && checksum.equals(sha256(Files.readAllBytes(target)))) { - return target; - } + if (Files.isRegularFile(target) && copy.checksum.equals(sha256File(target))) { + return target; + } - Path temporary = Files.createTempFile(directory, "pickleball-workbench-", ".tmp"); - try { - Files.write(temporary, payload); try { Files.move( temporary, @@ -102,14 +117,14 @@ static Path extractPayload(Path projectRoot, byte[] payload) throws IOException } catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); } + + if (!copy.checksum.equals(sha256File(target))) { + throw new IllegalStateException("Extracted Workbench payload failed checksum verification."); + } + return target; } finally { Files.deleteIfExists(temporary); } - - if (!checksum.equals(sha256(Files.readAllBytes(target)))) { - throw new IllegalStateException("Extracted Workbench payload failed checksum verification."); - } - return target; } static List command(Path controllerJar, String[] args) { @@ -148,11 +163,41 @@ private static Path javaExecutable() { return Path.of(System.getProperty("java.home"), "bin", executable); } - private static String sha256(byte[] bytes) { + private static HashedCopy copyAndHash(InputStream input, Path target) throws IOException { + MessageDigest digest = sha256Digest(); + long written = 0; + try (DigestInputStream digested = new DigestInputStream(input, digest); + OutputStream output = Files.newOutputStream(target)) { + byte[] buffer = new byte[65536]; + int read; + while ((read = digested.read(buffer)) >= 0) { + written += read; + if (written > MAX_PAYLOAD_BYTES) { + throw new IllegalStateException("Embedded Workbench payload exceeds the safety limit."); + } + output.write(buffer, 0, read); + } + } + return new HashedCopy(written, HexFormat.of().formatHex(digest.digest())); + } + + private static String sha256File(Path file) throws IOException { + MessageDigest digest = sha256Digest(); + try (InputStream input = Files.newInputStream(file); + DigestInputStream digested = new DigestInputStream(input, digest)) { + digested.transferTo(OutputStream.nullOutputStream()); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static MessageDigest sha256Digest() { try { - return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + return MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 is unavailable.", impossible); } } + + private record HashedCopy(long bytes, String checksum) { + } } diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index 3ba8c647..0adb407d 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player.LiveScenarioPlayer`; `tools.dscode.workbench.ui`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `WorkbenchSynchronizerTest`; `WorkbenchGradleSynchronizerIntegrationTest`; `WorkbenchWorkerManagerTest`; `WorkbenchLiveSessionTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; `ControlBridgeClientTest`; direct `sync` / `worker-check` / `live-check`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. The default buffer is a Workbench-owned browser demo against `URL.home`. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 9f9807dc..1a082cd2 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -476,13 +476,30 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/AGENTS.md` - `pickleball-workbench/build.gradle` - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/MappingEditorHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WebViewPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java` @@ -492,11 +509,26 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerStatus.java` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js` - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md index f7ca0bb2..1b9c6763 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md @@ -21,13 +21,21 @@ consumer worker JVM -> owns Pickleball, Cucumber, DynamicControl, Mapping/ParsingMap/NodeMap, browser, and service behavior ``` -The player and Mapping editor do not change this boundary. Swing is a presentation adapter over `WorkbenchServices` / `WorkbenchController`. Automatic buffered execution and add-and-continue use the existing `executeStep` worker contract; Workbench does not invent a second Gherkin matcher. +The player, picker, Mapping editor, Terminal, and Diagnostic explorer do not change this boundary. Swing and WebView are presentation adapters over `WorkbenchServices` / `WorkbenchController`. Automatic buffered execution and add-and-continue use the existing `executeStep` worker contract; Workbench does not invent a second Gherkin matcher. WebView JavaScript never executes Gherkin. + +## Feature and scenario picker + +The expandable left rail lists `.feature` files from the synchronized consumer project: manifest source roots, conventional `src/test/resources/features`, live merged `features/` resources, and an explicit project-owned `pkb_features` value when present. It does not crawl an unrelated git worktree. + +Toggle browse mode between Gherkin Feature name and file name + directory path. Click selects or deselects features. The scenario list then shows scenarios under the selection. With no feature selected, the search bar filters every scenario in that project catalog. Clicking a scenario loads it into the live editor. The default demo remains loaded until a scenario is chosen. **Save** is the only write-back path. ## Live Scenario Editor -The left-side editor is a session-scoped Gherkin text editor with a player playhead. Clicking a line instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. +The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Blocks are Gherkin text, including `Given` / `When` / `Then`. Nested steps and `IF` / `ELSE` snap as parent/child using leading colons. Clicking a block instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. + +Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. -The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files. The default demo is a small browser scenario against the Maven consumer local test site: +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario. The default demo is a small browser scenario against the Maven consumer local test site: ```gherkin Feature: Workbench Live Scenario @@ -69,12 +77,12 @@ Historical raw detached-step input remains supported. ## Mapping tab -The Mapping tab is an object editor rather than a get/put/resolve form. +The Mapping tab is a structured object/property editor rather than a get/put/resolve form. It contains: 1. A **NodeMap** dropdown populated from the actual NodeMaps in the current worker-side `ParsingMap`. -2. One editable JSON text area containing the materialized root object of the selected NodeMap. +2. A property tree for that NodeMap. Each row edits key, value text, and type (`string`, `numeric`, `boolean`, `object-as-JSON`, `object-as-XML`). The dropdown is populated through the existing Mapping snapshot contract using a reserved neutral protocol reference. The worker resolves the reserved reference to a catalog generated from the current `ParsingMap`; the Workbench sees only neutral snapshot data. @@ -84,15 +92,20 @@ Each catalog entry uses a second reserved reference that resolves back to the sa For an ordinary restorable NodeMap: -- change scalar values directly; -- add or delete properties; -- add or edit nested objects; -- add or edit arrays; -- assign an object as a value by entering its JSON object structure. +- change scalar values in place and choose their type; +- add or rename properties; +- assign JSON or XML object text, which is decoded and sent as a structured `mappingPut` value; +- rename keys through `mappingRestore` of the current object. + +Invalid typed text is not sent to the worker. NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only, preserving the existing restore safety rule. + +## Terminal + +The Terminal tails the worker stdout/stderr files Workbench already creates under `.pickleball/workbench/logs/`. Filter by `TRACE`, `DEBUG`, `INFO`, `WARNING`, or `ERROR`. Logs continue as the playhead moves. This is not MCP stdout and is not a fabricated Workbench-only activity dump. Unmarked worker output is shown at `INFO`. -After a short debounce, valid JSON is restored through the existing `mapping_restore` bridge operation. Invalid intermediate JSON is not sent to the worker. +## Diagnostic Log Explorer -NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only, preserving the existing restore safety rule. +The explorer is a rewind/play/focus timeline of retained Pickleball diagnostic runs. Screenshot frames are shown with the Gherkin step that was running when they were taken. Denser layers follow the repository evidence order and only open when the retained files exist. If `reports/diagnostic-runs/run-catalog.json` is missing, the panel stays empty and says so. ## Focused validation @@ -102,7 +115,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, and the non-empty browser demo seed. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, and the non-empty browser demo seed. Workbench changes should continue to use the repository's focused validation policy: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index d3ba4e3d..5b8fa58d 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -70,7 +70,7 @@ tasks.register('pickleballWorkbench', JavaExec) { Run it with `./gradlew pickleballWorkbench` (or `gradlew.bat pickleballWorkbench`). The task uses the consumer's resolved test runtime only to locate the tiny launcher and nested bytes; actual controller code still starts in a separate `java -jar` process. -The launcher reads the nested payload, limits its size, calculates SHA-256, and extracts it atomically to: +The launcher streams the nested payload (it does not load the controller JAR into a byte array), hashes SHA-256 while copying, and rejects payloads larger than 512 MiB. OpenJFX WebView natives make the executable larger than a plain Java controller. It extracts atomically to: ```text .pickleball/workbench/controller//pickleball-workbench.jar @@ -115,26 +115,29 @@ The Swing UI is a presentation adapter over the same `WorkbenchServices` / `Work ### Player-style layout -The primary workspace is now arranged as an interactive scenario player: +The primary workspace is an interactive Gherkin player with a project feature picker: ```text -+-------------------------------------------------------------------+ -| Project / readiness Play Pause Stop Player status | -+----------------------------------+--------------------------------+ -| LIVE SCENARIO EDITOR | Mapping | Terminal | Diagnostic| -| (in-place Gherkin text) | | -| Feature: ... | selected right-side workspace | -| Scenario: ... | | -| highlighted playhead step | | -+----------------------------------+ | -| Step Editor [Step] [From Here] | | -| [ live command text ] | | -+----------------------------------+--------------------------------+ -| Workbench/session activity | -+-------------------------------------------------------------------+ ++-------------------------------------------------------------------------+ +| Features | Project / readiness Play Pause Stop Player status | ++----------+---------------------------+----------------------------------+ +| Feature /| LIVE GHERKIN EDITOR | Mapping | Terminal | Diagnostic | +| file | (WebView block editor) | WebView tree / typed values | +| picker | highlighted playhead | worker log / retained-run frames | +| + search | | | ++----------+---------------------------+ | +| | Step Editor [Step] [From | | +| | Here] [ live command ] | | ++----------+---------------------------+----------------------------------+ +| Footer activity | ++-------------------------------------------------------------------------+ ``` -The left side contains only the Live Scenario Editor and compact Step Editor / Command. The right side has one tabbed workspace for Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls are available from the **Session** menu. Existing investigation tools are available from **Tools > Advanced Controls** so the underlying capabilities are preserved without dominating the normal workflow. +The expandable left rail lists feature files from the synchronized consumer project. Browse by Gherkin Feature name or by file name + directory path. Click toggles feature selection. The scenario list is limited to selected features, or to every project scenario when nothing is selected; the search bar filters that list. Clicking a scenario loads it into the live session buffer. Workbench does not write `.feature` files unless you use the explicit **Save** control. + +The center editor is an embedded HTML/JS block editor hosted in JavaFX `WebView` (`JFXPanel`). Blocks are Gherkin text — including `Given` / `When` / `Then` — not a second language compiled to Gherkin. Nested steps and `IF` / `ELSE` blocks snap as parent/child using Pickleball's leading-colon grammar. The play header is unchanged: click-to-seek, global **Play** from the first step in a fresh worker context, **Step** = isolated `executeStep`, **From Here** = selected/playhead through the rest, stay in play at end, Enter append-and-run. JavaScript never executes Gherkin. + +The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls stay under **Session**. Existing investigation tools stay under **Tools > Advanced Controls**. ### Live scenario buffer and player state @@ -146,7 +149,7 @@ The left side contains only the Live Scenario Editor and compact Step Editor / C - playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -The Live Scenario Editor is a regular in-place Gherkin editor. Users can type at any line, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. The buffer is a session document; Workbench does not write it back to consumer `.feature` files. +The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live; **Save** writes back to the originating `.feature` file when one was loaded. The Workbench-owned demo has no save path. The playhead behaves like an audio-player needle: @@ -193,25 +196,29 @@ Scenario: Open the local test site ### Mapping tab -There is no GUI-defined `Current Scope` concept. The Mapping tab is one NodeMap selector populated from the current worker-side `ParsingMap`, plus one JSON object editor for the selected NodeMap root. Valid JSON edits are restored through the existing `mappingRestore` service. The GUI must not hard-code NodeMap names or recreate inheritance rules. +There is no GUI-defined `Current Scope` concept and no hard-coded NodeMap names. The Mapping tab is a structured property tree populated from the actual NodeMaps in the current worker-side `ParsingMap`. Each property is edited in place: key, value text, and a type dropdown (`string`, `numeric`, `boolean`, `object-as-JSON`, `object-as-XML`). Typed writes go through the existing `mappingPut` service; key renames and whole-object replacement use `mappingRestore`. The GUI must not recreate inheritance rules or keep a second Mapping store. NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only. MCP continues to support arbitrary JSON-compatible Mapping values through the shared service methods. +### WebView packaging + +JDK 21 does not ship a modern browser panel. Workbench embeds OpenJFX `WebView` through `JFXPanel` for the Gherkin editor, Mapping tree, and Diagnostic explorer. That choice stays Workbench-only: Maven-central JavaFX modules are shaded into the controller executable, including platform natives under `javafx-natives//`. JCEF was not used because Chromium natives are harder to keep isolation-clean and do not package as ordinary Workbench dependencies. If JavaFX cannot start, the live editor falls back to the existing in-place text buffer on the same `LiveScenarioPlayer` model. + ### Terminal and Diagnostic Log Explorer -The Terminal tab currently displays Workbench UI activity only. Worker log streaming, level filtering, search, and auto-scroll belong to the Terminal phase and must use the appropriate worker/Workbench logging source without violating the MCP stdout contract. +The Terminal tab presents the existing consumer-worker stdout/stderr capture files under `.pickleball/workbench/logs/` as a scenario-run log. A dropdown filters `TRACE`, `DEBUG`, `INFO`, `WARNING`, and `ERROR`. Lines continue as the playhead moves because the panel tails those files and also records `executeStep` / Mapping results. Workbench does not redirect MCP stdout. If a worker log line has no printed level, it is shown at `INFO` rather than invented. Structured Pickleball logger output is used when present; there is no second log fabricator. -The Diagnostic Log Explorer tab remains a placeholder until it is bound to Pickleball's retained diagnostic artifacts. Its implementation must reuse Pickleball's retained diagnostic model and follow the existing evidence escalation order: +The Diagnostic Log Explorer is a WebView timeline over Pickleball's retained diagnostic artifacts (`reports/diagnostic-runs`). It follows the existing evidence escalation order and does not create a competing store or fake retained-run data: 1. `run-catalog.json` 2. selected `run-index.json` / `clusters.json` 3. scenario `summary.json` 4. relevant `events.jsonl` 5. existing comparison/fingerprint metadata -6. PNG only when visual content must be inspected +6. PNG frames only when a retained screenshot exists, shown with the Gherkin step text that was running when taken 7. raw trace only when structured evidence is insufficient -The Swing UI must not create a competing diagnostic storage format or fake retained-run data. +If the consumer project has no `run-catalog.json`, the explorer says so and stays empty. ### Existing advanced capabilities @@ -334,7 +341,7 @@ The build proves the controller boundary with `verifyStrictControllerIsolation` - top-level and nested Workbench entries and service descriptors contain no core/worker implementation or nested Pickleball runtime; - the published Workbench POM has no dependencies; - the MCP convenience artifact / Jackson 3 path is not used; -- the outer Pickleball JAR contains exactly one opaque Workbench payload whose bytes equal the standalone output; +- the outer Pickleball JAR contains exactly one opaque Workbench payload whose SHA-256 matches the standalone controller JAR; - Workbench/MCP entries are not flattened into the outer runtime namespace; and - the nested payload has the expected Workbench `Main-Class`. @@ -360,18 +367,20 @@ java -jar $workbenchJar ui ".\maven-consumer-project" Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. -1. Verify the top-level layout has the Live Scenario Editor and compact Step Editor on the left, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. -2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site. -3. Click different scenario steps and verify the playhead highlight moves immediately to the clicked line. +1. Verify the top-level layout has an expandable Features picker, the Live Gherkin Editor and compact Step Editor in the center, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. +2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site when no picker scenario is selected. +3. Click different scenario blocks and verify the playhead highlight moves immediately to the clicked step. 4. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. 5. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. 6. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. 7. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. 8. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. 9. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. -10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. The NodeMap JSON editor remains usable with a paused worker. -11. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. -12. Verify blocking runtime actions leave the Swing UI responsive. +10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Top-level properties come from the worker ParsingMap and accept typed in-place edits. +11. Verify Terminal filters worker log files by level and continues as steps run, without writing to MCP stdout. +12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. +13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +14. Verify blocking runtime actions leave the Swing UI responsive. ## Regression diff --git a/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java index f52e2544..929b4043 100644 --- a/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java +++ b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.ByteArrayInputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -52,6 +53,14 @@ void extractionIsContentAddressedAndCommandAlwaysStartsASeparateJarProcess() thr assertEquals(Path.of("").toAbsolutePath().normalize().toString(), defaults[1]); assertEquals("mcp", implicitProject[0]); assertEquals(Path.of("").toAbsolutePath().normalize().toString(), implicitProject[1]); + + byte[] streamed = {9, 8, 7, 6}; + Path fromStream = PickleballWorkbenchLauncher.extractPayload( + tempDir, + new ByteArrayInputStream(streamed) + ); + assertArrayEquals(streamed, Files.readAllBytes(fromStream)); + assertTrue(PickleballWorkbenchLauncher.MAX_PAYLOAD_BYTES >= 164L * 1024 * 1024); } private static String javaExecutableName() { From c5bc48864c285bbb35846f9213b636a246e09880 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 18:02:47 +0000 Subject: [PATCH 08/15] Add a watched AI-agent control-lease for Workbench live sessions. Swing and MCP/HTTP attach share one controller-owned lease and LiveScenarioPlayer. UI mode exposes a localhost JSON attach endpoint so an agent joins the visible session instead of starting a second worker. Save copies a picker-loaded scenario back to the originating feature only after explicit Allow. Co-authored-by: ds-code-t --- docs/agent/feature-map.md | 4 +- docs/agent/repository-index.md | 18 ++ docs/pickleball-workbench-player.md | 15 +- docs/pickleball-workbench.md | 76 ++++- pickleball-workbench/AGENTS.md | 10 +- .../WORKBENCH-PLAYER-CONTEXT.md | 2 +- pickleball-workbench/build.gradle | 5 +- .../workbench/WorkbenchApplication.java | 1 + .../dscode/workbench/WorkbenchController.java | 251 ++++++++++++++- .../dscode/workbench/WorkbenchServices.java | 58 ++++ .../workbench/lease/WorkbenchCallContext.java | 40 +++ .../lease/WorkbenchControlLease.java | 289 ++++++++++++++++++ .../lease/WorkbenchControlLeaseSnapshot.java | 40 +++ .../workbench/lease/WorkbenchLeaseHolder.java | 7 + ...WorkbenchPermissionCancelledException.java | 12 + .../lease/WorkbenchPermissionDecision.java | 8 + .../lease/WorkbenchPermissionKind.java | 6 + .../lease/WorkbenchPermissionRequest.java | 25 ++ .../workbench/mcp/WorkbenchAttachServer.java | 231 ++++++++++++++ .../workbench/mcp/WorkbenchMcpTools.java | 192 ++++++++---- .../workbench/player/LiveFeatureSave.java | 119 ++++++++ .../player/LivePlaybackCoordinator.java | 32 +- .../workbench/player/ScenarioOrigin.java | 33 ++ .../player/WorkbenchPlayerState.java | 33 ++ .../player/WorkbenchSavePreview.java | 29 ++ .../workbench/player/WorkbenchSaveResult.java | 42 +++ .../workbench/ui/FeaturePickerPanel.java | 22 +- .../dscode/workbench/ui/WorkbenchFrame.java | 207 ++++++++++++- .../dscode/workbench/ui/WorkbenchUi.java | 11 +- .../workbench/ui/WorkbenchUiController.java | 51 ++++ .../workbench/ui/web/WorkbenchWebJson.java | 16 + .../workbench/ui/web/gherkin-editor.css | 4 + .../dscode/workbench/ui/web/gherkin-editor.js | 18 +- .../dscode/workbench/ui/web/mapping-editor.js | 14 +- .../WorkbenchControllerLeaseTest.java | 168 ++++++++++ .../lease/WorkbenchControlLeaseTest.java | 138 +++++++++ .../mcp/WorkbenchAttachServerTest.java | 98 ++++++ .../workbench/mcp/WorkbenchMcpServerTest.java | 3 + .../workbench/player/LiveFeatureSaveTest.java | 98 ++++++ .../player/LivePlaybackCoordinatorTest.java | 1 + .../ui/WorkbenchUiControllerTest.java | 53 ++++ .../guidance/docs/agent/feature-map.md | 4 +- .../guidance/docs/agent/repository-index.md | 18 ++ .../docs/pickleball-workbench-player.md | 15 +- .../guidance/docs/pickleball-workbench.md | 76 ++++- 45 files changed, 2456 insertions(+), 137 deletions(-) create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 0adb407d..eb794478 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 1a082cd2..86a1c5f4 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -478,13 +478,27 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` @@ -521,9 +535,12 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` @@ -531,6 +548,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchLiveSessionTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java` diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md index 1b9c6763..d4a79d35 100644 --- a/docs/pickleball-workbench-player.md +++ b/docs/pickleball-workbench-player.md @@ -35,7 +35,7 @@ The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Block Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. -The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario. The default demo is a small browser scenario against the Maven consumer local test site: +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario and confirm the copy. The default demo is a small browser scenario against the Maven consumer local test site: ```gherkin Feature: Workbench Live Scenario @@ -107,6 +107,17 @@ The Terminal tails the worker stdout/stderr files Workbench already creates unde The explorer is a rewind/play/focus timeline of retained Pickleball diagnostic runs. Screenshot frames are shown with the Gherkin step that was running when they were taken. Denser layers follow the repository evidence order and only open when the retained files exist. If `reports/diagnostic-runs/run-catalog.json` is missing, the panel stays empty and says so. +## Watched-agent control lease + +The live player is a collaborative testing space, not a second editor. Swing and an attached agent share one `LiveScenarioPlayer` in the Workbench controller. + +- A human can work alone: edit/play the live buffer, then **Save** asks before copying into the original scenario in the original `.feature` file. +- An agent attaches to the running UI through `.pickleball/workbench/attach.json` (localhost JSON tools over the same `WorkbenchServices`). It must not start a second Workbench JVM or worker. +- After `workbench_request_control`, Swing play/edit/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. +- `workbench_request_save` is the only original-feature write path for the agent. With the UI attached it blocks on Allow/Deny. Deny writes nothing. Headless stdio MCP may hold the lease without a banner; Save is still an explicit tool. + +See [pickleball-workbench.md](pickleball-workbench.md) for attach discovery, tool names, and stdout rules. + ## Focused validation The included consumer `@control-bridge` scenario verifies: @@ -115,7 +126,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, and the non-empty browser demo seed. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 5b8fa58d..e63e0903 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -22,7 +22,7 @@ Workbench owns synchronization, `.pickleball/workbench/` disposable state, worke `pickleball-control-protocol` owns only immutable wire records, request/response envelopes, transport constants, capabilities, and version/minimum-version negotiation. Worker-side bridge server/coordinator/bootstrap and all translation to runtime operations remain in Pickleball core. -MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. +MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. A visible UI keeps one Workbench JVM and one consumer worker. An AI agent attaches to that live session through a localhost HTTP JSON facade; it must not start a second Workbench or a second worker. The canonical worker bridge environment is: @@ -149,7 +149,7 @@ The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level - playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live; **Save** writes back to the originating `.feature` file when one was loaded. The Workbench-owned demo has no save path. +The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live. **Save** is confirmation-gated: it copies the live scenario into the originating `.feature` file and scenario only after Allow. The Workbench-owned demo has no save path. Workbench never writes `.feature` files on picker load or on Deny. The playhead behaves like an audio-player needle: @@ -242,6 +242,63 @@ Synchronization, worker actions, live bridge calls, Mapping operations, event re The UI is intentionally not a project IDE, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. The Live Scenario Editor is a session-scoped Gherkin player/editor, not a workspace file explorer and not an automatic writer of consumer `.feature` files. +### Watched AI-agent control lease + +Workbench owns one control lease for the live session. Swing and MCP/HTTP adapters share it; the lease is not Swing-only state. + +- Holder is `HUMAN` when the UI is up, or `AGENT` after an attached agent requests control. +- The snapshot also carries the agent display name, `currentAction` banner text, and at most one pending permission request. + +While the human holds the lease, Swing play/edit/mapping/save/worker controls stay enabled. Agent mutating calls fail clearly until `workbench_request_control`. + +While an agent holds the lease, the human can watch the same window. Play, edit, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. + +The agent should update `currentAction` as it works. Playhead, Mapping, Terminal, and screenshots follow because the agent uses the same `LiveScenarioPlayer` / worker as the UI. Testing the live scenario (`executeStep`, play, Mapping reads, evidence) is allowed on the agent lease. Copying the live scenario into the original `.feature` is not; that goes through `workbench_request_save` and waits for Allow/Deny in the Swing banner. + +Human **Save** uses the same service. After a picker scenario was loaded, Swing asks: copy these live steps into file X / scenario Y? Deny writes nothing. The demo buffer stays unsavable. + +### Attaching an agent to a visible UI + +UI mode cannot share process stdout with stdio MCP. Starting `mcp` while the UI is already running would be a second Workbench JVM. Instead, `ui` starts a 127.0.0.1-only JSON attach endpoint over the same `WorkbenchServices` / `WorkbenchMcpTools` methods and writes disposable discovery state: + +```text +.pickleball/workbench/attach.json +``` + +Example: + +```json +{ + "url": "http://127.0.0.1:51234", + "token": "hex-session-token", + "pid": 12345, + "project": "/path/to/maven-consumer-project", + "mode": "ui-attach", + "bind": "127.0.0.1" +} +``` + +A Copilot or other MCP-style client finds that file in the consumer project, then: + +1. `GET {url}/health` — liveness, no token. +2. `GET {url}/lease` and `GET {url}/player` — `Authorization: Bearer ` or `X-Workbench-Token`. +3. `POST {url}/tools/workbench_request_control` with `{"agentName":"Copilot"}`. +4. Use the existing live tools (`workbench_execute_step`, Mapping, evidence, worker) while holding the lease, and `workbench_set_current_action` so the human can watch. +5. `POST {url}/tools/workbench_request_save` to ask to copy the live scenario into the original feature. The call blocks until the human clicks Allow or Deny, or Take control. + +Headless `java -jar pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. + +From `maven-consumer-project`: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +Then point the agent at `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session. + ## MCP stdio Start the lightweight non-Spring MCP server for a synchronized consumer project: @@ -283,9 +340,16 @@ workbench_worker_stop workbench_worker_status ``` -Live runtime and Mapping: +Live runtime, Mapping, and watched-agent control: ```text +workbench_request_control +workbench_release_control +workbench_set_current_action +workbench_control_lease +workbench_player_state +workbench_player_replace_document +workbench_request_save workbench_execute_step workbench_mapping_get workbench_mapping_put @@ -324,6 +388,8 @@ workbench_step_override_clear `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. +Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. + Controller/runtime failures are returned as MCP tool results with `isError=true`. They are not printed as arbitrary protocol output. ## Scope boundary @@ -381,6 +447,10 @@ Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-c 12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. 13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. 14. Verify blocking runtime actions leave the Swing UI responsive. +15. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. +16. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/mapping/save/worker controls. **Take control** remains enabled. +17. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. +18. The default demo remains unsavable for both human Save and agent `workbench_request_save`. ## Regression diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 76fc81a4..5ceeedb4 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -24,9 +24,9 @@ Workbench-only dependencies, including Jackson and the MCP SDK, belong only on t ## Runtime ownership -The Workbench controller owns synchronization, worker process/session lifecycle, bridge client behavior, MCP stdio, and the thin Swing UI. Pickleball owns consumer-worker behavior such as the bridge server/coordinator, DynamicControl/Gherkin execution, Step Override runtime, Mapping state, browser/service-call access, and woven Cucumber integration. +The Workbench controller owns synchronization, worker process/session lifecycle, bridge client behavior, MCP stdio, the localhost UI-attach endpoint, the watched-agent control lease, and the thin Swing UI. Pickleball owns consumer-worker behavior such as the bridge server/coordinator, DynamicControl/Gherkin execution, Step Override runtime, Mapping state, browser/service-call access, and woven Cucumber integration. -`WorkbenchServices` is the shared plain-Java adapter boundary. `WorkbenchController` composes synchronization and `WorkbenchLiveSession`; MCP and Swing must delegate to that service surface instead of implementing their own worker ownership, bridge calls, Mapping semantics, Step Override behavior, or scenario retry rules. +`WorkbenchServices` is the shared plain-Java adapter boundary. `WorkbenchController` composes synchronization, `WorkbenchLiveSession`, `LiveScenarioPlayer`, and the control lease; MCP, HTTP attach, and Swing must delegate to that service surface instead of implementing their own worker ownership, bridge calls, Mapping semantics, Step Override behavior, scenario retry rules, or a second live Gherkin document. The Workbench bridge client uses only `tools.dscode.control.protocol.*`. Worker-side `ControlBridgeRuntime`, `ControlBridgeCoordinator`, bootstrap, adapters, step compilation, mappings, and execution semantics stay in Pickleball. Workbench may hold the worker entry-point class name as `ControlProtocol.WORKER_MAIN_CLASS`; it must never import or load that class. @@ -75,7 +75,7 @@ Low-level lifecycle controls live under the Session menu and existing investigat `LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, the editable Gherkin document, selected line, playhead, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. -The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless the user uses explicit **Save**. WebView JavaScript must not execute Gherkin. +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless **Save** is explicitly approved. Human Save asks before copying the live scenario into the original scenario in the original `.feature` file. An attached agent must use `workbench_request_save` and wait for Allow/Deny when the UI is present. Deny and Take control write nothing. WebView JavaScript must not execute Gherkin. The Step Editor has two play actions: **Step** executes only the editor text through `WorkbenchServices.executeStep` and leaves automatic playback paused; **From Here** restarts into a fresh scenario context and runs from the selected/playhead step through the rest of the buffer. Enter while waiting at end appends the step and continues the live run. Do not strip Gherkin keywords or add a Swing-side step matcher. Worker-side `DynamicControl` / `GherkinControl` remain the only Gherkin interpreters. @@ -103,7 +103,9 @@ Workbench provides: java -jar pickleball-workbench-.jar mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, and Step Override authoring through `WorkbenchServices`. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, and gated Save through `WorkbenchServices`. + +UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. MCP stdout is a hard protocol boundary. `WorkbenchApplication` reserves the original process stdout for the stdio transport and redirects ordinary `System.out` output to stderr before constructing the MCP SDK/controller. The executable must explicitly remain alive for the stdio session until stdin reaches EOF; do not rely on MCP SDK worker-thread liveness to keep the JVM running. Workbench diagnostic text must use stderr or `.pickleball/workbench/logs/`; worker stdout/stderr remain separately redirected to worker log files. No banner, normal log, worker output, test output, or diagnostic chatter may be written to MCP stdout. diff --git a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md index 309dc95b..31bd0270 100644 --- a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md +++ b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md @@ -10,7 +10,7 @@ This file records the live-player behavior added on top of those unchanged bound The playhead is the user-visible needle. Clicking a scenario line instantly seeks it, like clicking a waveform. Global Play ignores the playhead and always starts from the first executable buffer step in a fresh interactive worker context. -The Live Scenario Editor is an in-place Gherkin document shown as WebView blocks whose text is Gherkin. Users can type at any block, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files unless the user uses explicit Save. The left rail picks features/scenarios from the synchronized consumer project. +The Live Scenario Editor is an in-place Gherkin document shown as WebView blocks whose text is Gherkin. Users can type at any block, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files unless **Save** is explicitly approved. Human Save confirms before copying into the original scenario. An attached AI agent can request the controller-owned control lease, lock Swing while the human watches, and must ask permission to write the original feature. Take control always returns the floor to the human. The Step Editor exposes two distinct execution actions: diff --git a/pickleball-workbench/build.gradle b/pickleball-workbench/build.gradle index ba9f7eff..d251577d 100644 --- a/pickleball-workbench/build.gradle +++ b/pickleball-workbench/build.gradle @@ -574,7 +574,10 @@ tasks.register('verifyWorkbenchMcpStdio') { if (!names.containsAll([ 'workbench_worker_status', 'workbench_execute_step', - 'workbench_step_override_compile' + 'workbench_step_override_compile', + 'workbench_request_control', + 'workbench_player_state', + 'workbench_request_save' ])) { throw new GradleException("Workbench MCP tool list is incomplete: ${names}") } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java index 7a3a2d6b..16ef3d90 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java @@ -436,6 +436,7 @@ private static void printUsage(PrintStream out) { out.println("worker-check starts, restarts, and gracefully stops direct consumer workers without rebuilding."); out.println("live-check exercises raw Gherkin, Step Override, and live runtime operations on one persistent worker."); out.println("mcp serves the same Workbench services over protocol-only stdio; diagnostics use stderr/log files."); + out.println("ui opens the thin Swing Workbench over the same controller services and writes a localhost agent-attach endpoint to .pickleball/workbench/attach.json."); out.println("ui opens the thin Swing Workbench over the same controller services."); } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index f2d7b6a8..717e4d9d 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -1,6 +1,19 @@ package tools.dscode.workbench; import tools.dscode.control.protocol.*; +import tools.dscode.workbench.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.lease.WorkbenchPermissionCancelledException; +import tools.dscode.workbench.lease.WorkbenchPermissionDecision; +import tools.dscode.workbench.lease.WorkbenchPermissionKind; +import tools.dscode.workbench.lease.WorkbenchPermissionRequest; +import tools.dscode.workbench.player.LiveFeatureSave; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.ScenarioOrigin; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; import tools.dscode.workbench.terminal.WorkerLogFiles; @@ -10,21 +23,191 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.Supplier; /** Plain-Java controller shared by Workbench adapters. */ public final class WorkbenchController implements WorkbenchServices { private final Path projectRoot; private final WorkbenchSynchronizer synchronizer; private final WorkbenchLiveSession live; + private final LiveScenarioPlayer player; + private final LivePlaybackCoordinator playback; + private final WorkbenchControlLease lease; + private final List playerListeners = new CopyOnWriteArrayList<>(); public WorkbenchController(Path projectRoot) { this.projectRoot = projectRoot.toAbsolutePath().normalize(); this.synchronizer = new WorkbenchSynchronizer(); this.live = new WorkbenchLiveSession(this.projectRoot); + this.player = LiveScenarioPlayer.interactiveBuffer(); + this.playback = new LivePlaybackCoordinator(this.player); + this.lease = new WorkbenchControlLease(); + } + + @Override + public LiveScenarioPlayer player() { + return player; + } + + @Override + public LivePlaybackCoordinator playback() { + return playback; + } + + @Override + public WorkbenchPlayerState playerState() { + ScenarioOrigin origin = playback.origin(); + LiveScenarioPlayer.Line playhead = player.playheadLine().orElse(null); + return new WorkbenchPlayerState( + player.documentText(), + player.lines().stream().map(LiveScenarioPlayer.Line::text).toList(), + player.state(), + player.playheadId().isPresent() ? player.playheadId().getAsLong() : null, + playhead == null ? "" : playhead.text(), + player.selectedId().isPresent() ? player.selectedId().getAsLong() : null, + origin.file() == null ? "" : origin.file().toString(), + origin.scenarioName(), + origin.savable() + ); + } + + @Override + public WorkbenchControlLease controlLease() { + return lease; + } + + @Override + public WorkbenchControlLeaseSnapshot controlLeaseSnapshot() { + return lease.snapshot(); + } + + @Override + public WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName) { + return lease.requestControl(agentDisplayName); + } + + @Override + public WorkbenchControlLeaseSnapshot releaseControl() { + return lease.releaseControl(); + } + + @Override + public WorkbenchControlLeaseSnapshot takeControl() { + return lease.takeControl(); + } + + @Override + public WorkbenchControlLeaseSnapshot setCurrentAction(String text) { + return lease.setCurrentAction(text); + } + + @Override + public void answerPermission(String requestId, boolean allow) { + lease.answerPermission(requestId, allow); + } + + @Override + public void attachUi() { + lease.attachUi(); + } + + @Override + public void detachUi() { + lease.detachUi(); + } + + @Override + public void addLeaseListener(Consumer listener) { + lease.addListener(listener); + } + + @Override + public void removeLeaseListener(Consumer listener) { + lease.removeListener(listener); + } + + @Override + public void addPlayerListener(Runnable listener) { + playerListeners.add(listener); + } + + @Override + public void removePlayerListener(Runnable listener) { + playerListeners.remove(listener); + } + + @Override + public void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + requireMutating(); + playback.loadScenario(lines, originFile, scenarioName, startLine, endLine); + notifyPlayer(); + } + + @Override + public void loadDefaultDemo() { + requireMutating(); + playback.loadDefaultDemo(); + notifyPlayer(); + } + + @Override + public void replaceLiveDocument(List lines) { + requireMutating(); + playback.replaceFromLines(lines); + notifyPlayer(); + } + + @Override + public WorkbenchSavePreview savePreview() { + return LiveFeatureSave.preview(playback); + } + + @Override + public WorkbenchSaveResult requestSave() { + requireMutating(); + WorkbenchSavePreview preview = LiveFeatureSave.preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + WorkbenchPermissionRequest request = new WorkbenchPermissionRequest( + WorkbenchControlLease.newPermissionId(), + WorkbenchPermissionKind.SAVE, + preview.summary(), + preview.featurePath() == null ? "" : preview.featurePath().toString(), + preview.scenarioName() + ); + try { + WorkbenchPermissionDecision decision = lease.awaitPermission(request); + if (decision != WorkbenchPermissionDecision.ALLOW) { + return WorkbenchSaveResult.denied(); + } + return LiveFeatureSave.write(playback); + } catch (WorkbenchPermissionCancelledException cancelled) { + return WorkbenchSaveResult.cancelled(cancelled.getMessage()); + } + } + + @Override + public WorkbenchSaveResult commitSave() { + requireMutating(); + WorkbenchSavePreview preview = LiveFeatureSave.preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + return LiveFeatureSave.write(playback); } @Override public WorkbenchManifest synchronize() { + requireMutating(); if (live.status().running()) { throw new IllegalStateException("Stop the Workbench worker before synchronizing the project."); } @@ -38,17 +221,17 @@ public WorkbenchManifest synchronizationStatus() { @Override public WorkbenchWorkerStatus startWorker() { - return live.start(); + return mutating(live::start); } @Override public WorkbenchWorkerStatus restartWorker() { - return live.restart(); + return mutating(live::restart); } @Override public WorkbenchWorkerStatus stopWorker() { - return live.stop(); + return mutating(live::stop); } @Override @@ -68,7 +251,10 @@ public Optional workerLogFiles() { @Override public ControlBridgeCallResult executeStep(String text, String argument) { - return live.executeStep(text, argument == null ? "" : argument); + requireMutating(); + ControlBridgeCallResult result = live.executeStep(text, argument == null ? "" : argument); + maybeAdvancePlayhead(text, "SUCCESS".equals(result.status())); + return result; } @Override @@ -78,7 +264,7 @@ public ControlBridgeValueResult mappingGet(String mapReference, String key) { @Override public ControlBridgeValueResult mappingPut(String mapReference, String key, Object value) { - return live.mappingPut(mapReference, key, value); + return mutating(() -> live.mappingPut(mapReference, key, value)); } @Override @@ -93,7 +279,7 @@ public ControlBridgeMappingSnapshotResult mappingSnapshot(String mapReference) { @Override public ControlBridgeCallResult mappingRestore(ControlBridgeMappingSnapshot snapshot) { - return live.mappingRestore(snapshot); + return mutating(() -> live.mappingRestore(snapshot)); } @Override @@ -120,7 +306,7 @@ public ControlBridgeElementInspectionResult elementInspect( @Override public ControlBridgeServiceCallResult serviceCall(String selector) { - return live.serviceCall(selector); + return mutating(() -> live.serviceCall(selector)); } @Override @@ -137,19 +323,19 @@ public ControlBridgeBreakpoint addBreakpoint( boolean oneShot, Integer leaseSeconds ) { - return live.addBreakpoint( + return mutating(() -> live.addBreakpoint( hook, signatureContains, stepContains, phraseContains, oneShot, leaseSeconds - ); + )); } @Override public boolean removeBreakpoint(String breakpointId) { - return live.removeBreakpoint(breakpointId); + return mutating(() -> live.removeBreakpoint(breakpointId)); } @Override public int clearBreakpoints() { - return live.clearBreakpoints(); + return mutating(live::clearBreakpoints); } @Override @@ -159,21 +345,58 @@ public List stepOverrides() { @Override public ControlBridgeStepOverrideResult compileStepOverride(String id, String regex, String source) { - return live.compileStepOverride(id, regex, source); + return mutating(() -> live.compileStepOverride(id, regex, source)); } @Override public boolean removeStepOverride(String id) { - return live.removeStepOverride(id); + return mutating(() -> live.removeStepOverride(id)); } @Override public int clearStepOverrides() { - return live.clearStepOverrides(); + return mutating(live::clearStepOverrides); } @Override public void close() { + lease.detachUi(); live.close(); } + + private void maybeAdvancePlayhead(String text, boolean successful) { + LiveScenarioPlayer.Line next = player.nextStep().orElse(null); + if (next != null && text != null && next.text().equals(text) + && player.state() == LiveScenarioPlayer.State.RUNNING) { + try { + if (successful) { + player.markCurrentStepExecuted(next.id()); + } else { + player.markCurrentStepFailed(next.id()); + } + } catch (RuntimeException ignored) { + // Playhead follow is best-effort presentation; worker execution already finished. + } + } + notifyPlayer(); + } + + private T mutating(Supplier action) { + requireMutating(); + return action.get(); + } + + private void requireMutating() { + lease.requireMutatingAccess(); + } + + private void notifyPlayer() { + for (Runnable listener : playerListeners) { + try { + listener.run(); + } catch (RuntimeException ignored) { + // Presentation listeners must not break live execution. + } + } + } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java index fabd19cb..a7b5ce0f 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -12,6 +12,13 @@ import tools.dscode.control.protocol.ControlBridgeStepOverride; import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; import tools.dscode.control.protocol.ControlBridgeValueResult; +import tools.dscode.workbench.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -19,9 +26,60 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; /** Shared Workbench controller surface used by protocol and presentation adapters. */ public interface WorkbenchServices extends AutoCloseable { + LiveScenarioPlayer player(); + + LivePlaybackCoordinator playback(); + + WorkbenchPlayerState playerState(); + + WorkbenchControlLease controlLease(); + + WorkbenchControlLeaseSnapshot controlLeaseSnapshot(); + + WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName); + + WorkbenchControlLeaseSnapshot releaseControl(); + + WorkbenchControlLeaseSnapshot takeControl(); + + WorkbenchControlLeaseSnapshot setCurrentAction(String text); + + void answerPermission(String requestId, boolean allow); + + void attachUi(); + + void detachUi(); + + void addLeaseListener(Consumer listener); + + void removeLeaseListener(Consumer listener); + + void addPlayerListener(Runnable listener); + + void removePlayerListener(Runnable listener); + + void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ); + + void loadDefaultDemo(); + + void replaceLiveDocument(List lines); + + WorkbenchSavePreview savePreview(); + + WorkbenchSaveResult requestSave(); + + WorkbenchSaveResult commitSave(); + WorkbenchManifest synchronize(); WorkbenchManifest synchronizationStatus(); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java new file mode 100644 index 00000000..28b04753 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.lease; + +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Identifies whether the current thread is acting as the human UI adapter or as + * an attached AI agent. Adapters set this around {@code WorkbenchServices} calls + * so lease checks stay in Workbench rather than Swing or MCP. + */ +public final class WorkbenchCallContext { + private static final ThreadLocal HOLDER = + ThreadLocal.withInitial(() -> WorkbenchLeaseHolder.HUMAN); + + private WorkbenchCallContext() { + } + + public static WorkbenchLeaseHolder current() { + return HOLDER.get(); + } + + public static void runAs(WorkbenchLeaseHolder holder, Runnable action) { + callAs(holder, () -> { + action.run(); + return null; + }); + } + + public static T callAs(WorkbenchLeaseHolder holder, Supplier action) { + Objects.requireNonNull(holder, "holder"); + Objects.requireNonNull(action, "action"); + WorkbenchLeaseHolder previous = HOLDER.get(); + HOLDER.set(holder); + try { + return action.get(); + } finally { + HOLDER.set(previous); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java new file mode 100644 index 00000000..7f9f3a01 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java @@ -0,0 +1,289 @@ +package tools.dscode.workbench.lease; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +/** + * Controller-owned live-control lease. Swing and MCP/HTTP adapters observe this + * state; they do not keep a second copy. + */ +public final class WorkbenchControlLease { + private static final long PERMISSION_WAIT_NS = TimeUnit.MINUTES.toNanos(30); + + private final ReentrantLock lock = new ReentrantLock(); + private final Condition permissionAnswered = lock.newCondition(); + private final List> listeners = new CopyOnWriteArrayList<>(); + + private WorkbenchLeaseHolder holder = WorkbenchLeaseHolder.HUMAN; + private String agentDisplayName = ""; + private String currentAction = ""; + private boolean uiAttached; + private WorkbenchPermissionRequest pendingPermission; + private WorkbenchPermissionDecision pendingDecision; + + public void addListener(Consumer listener) { + listeners.add(Objects.requireNonNull(listener, "listener")); + listener.accept(snapshot()); + } + + public void removeListener(Consumer listener) { + listeners.remove(listener); + } + + public void attachUi() { + lock.lock(); + try { + uiAttached = true; + if (holder == WorkbenchLeaseHolder.AGENT) { + // A visible UI always starts with the human holding the floor. + } else { + holder = WorkbenchLeaseHolder.HUMAN; + } + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public void detachUi() { + lock.lock(); + try { + uiAttached = false; + failPendingLocked("The Workbench UI closed before the permission request was answered."); + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public WorkbenchControlLeaseSnapshot snapshot() { + lock.lock(); + try { + return snapshotLocked(); + } finally { + lock.unlock(); + } + } + + public WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName) { + String name = requiredName(agentDisplayName); + lock.lock(); + try { + if (holder == WorkbenchLeaseHolder.AGENT + && !this.agentDisplayName.isBlank() + && !this.agentDisplayName.equals(name)) { + throw new IllegalStateException( + "Another AI agent already holds the Workbench control lease (" + + this.agentDisplayName + ")." + ); + } + holder = WorkbenchLeaseHolder.AGENT; + this.agentDisplayName = name; + if (currentAction.isBlank()) { + currentAction = "Waiting to work in the live scenario."; + } + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot releaseControl() { + requireAgentCaller(); + lock.lock(); + try { + if (holder != WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException("The AI agent does not currently hold the Workbench control lease."); + } + failPendingLocked("The AI agent released control before the permission request was answered."); + holder = WorkbenchLeaseHolder.HUMAN; + agentDisplayName = ""; + currentAction = ""; + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot takeControl() { + lock.lock(); + try { + failPendingLocked("The human took control before the permission request was answered."); + holder = WorkbenchLeaseHolder.HUMAN; + agentDisplayName = ""; + currentAction = ""; + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot setCurrentAction(String text) { + requireAgentCaller(); + lock.lock(); + try { + requireHolderLocked(WorkbenchLeaseHolder.AGENT); + currentAction = text == null ? "" : text.strip(); + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + /** + * Live testing and Mapping/evidence reads stay available to the lease holder. + * Call this before mutating worker, player-buffer, Mapping write, or Save paths. + */ + public void requireMutatingAccess() { + WorkbenchLeaseHolder caller = WorkbenchCallContext.current(); + lock.lock(); + try { + requireHolderLocked(caller); + } finally { + lock.unlock(); + } + } + + public boolean uiAttached() { + lock.lock(); + try { + return uiAttached; + } finally { + lock.unlock(); + } + } + + /** + * Blocks when a UI is attached until Allow, Deny, or Take control. Headless + * stdio has no banner, so the explicit tool call itself is the approval. + */ + public WorkbenchPermissionDecision awaitPermission(WorkbenchPermissionRequest request) { + Objects.requireNonNull(request, "request"); + requireAgentCaller(); + lock.lock(); + try { + requireHolderLocked(WorkbenchLeaseHolder.AGENT); + if (pendingPermission != null) { + throw new IllegalStateException("Another Workbench permission request is already pending."); + } + if (!uiAttached) { + return WorkbenchPermissionDecision.ALLOW; + } + pendingPermission = request; + pendingDecision = null; + } finally { + lock.unlock(); + } + notifyListeners(); + + lock.lock(); + try { + long remaining = PERMISSION_WAIT_NS; + while (pendingDecision == null && remaining > 0) { + remaining = permissionAnswered.awaitNanos(remaining); + } + WorkbenchPermissionDecision decision = pendingDecision; + pendingPermission = null; + pendingDecision = null; + if (decision == null) { + throw new WorkbenchPermissionCancelledException( + "Timed out waiting for a human Allow/Deny decision." + ); + } + if (decision == WorkbenchPermissionDecision.CANCELLED) { + throw new WorkbenchPermissionCancelledException(); + } + return decision; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + pendingPermission = null; + pendingDecision = null; + throw new WorkbenchPermissionCancelledException("Interrupted while waiting for Save permission."); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public void answerPermission(String requestId, boolean allow) { + lock.lock(); + try { + if (pendingPermission == null || !pendingPermission.id().equals(requestId)) { + throw new IllegalStateException("No matching Workbench permission request is pending."); + } + pendingDecision = allow ? WorkbenchPermissionDecision.ALLOW : WorkbenchPermissionDecision.DENY; + permissionAnswered.signalAll(); + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public static String newPermissionId() { + return UUID.randomUUID().toString(); + } + + private void failPendingLocked(String message) { + if (pendingPermission == null) return; + pendingDecision = WorkbenchPermissionDecision.CANCELLED; + permissionAnswered.signalAll(); + // Keep pendingPermission until the waiter clears it so the UI can drop the banner. + pendingPermission = pendingPermission; + } + + private void requireHolderLocked(WorkbenchLeaseHolder expected) { + if (holder == expected) return; + if (expected == WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException( + "The AI agent does not hold the Workbench control lease. Call workbench_request_control first." + ); + } + throw new IllegalStateException( + "An AI agent currently holds the Workbench control lease. Take control before using these controls." + ); + } + + private static void requireAgentCaller() { + if (WorkbenchCallContext.current() != WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException("Only an attached AI agent can use this control-lease action."); + } + } + + private WorkbenchControlLeaseSnapshot snapshotLocked() { + return new WorkbenchControlLeaseSnapshot( + holder, + agentDisplayName, + currentAction, + uiAttached, + pendingPermission + ); + } + + private void notifyListeners() { + WorkbenchControlLeaseSnapshot snapshot = snapshot(); + for (Consumer listener : listeners) { + try { + listener.accept(snapshot); + } catch (RuntimeException ignored) { + // Presentation listeners must not break lease transitions. + } + } + } + + private static String requiredName(String agentDisplayName) { + if (agentDisplayName == null || agentDisplayName.isBlank()) { + throw new IllegalArgumentException("Agent display name must not be blank."); + } + return agentDisplayName.strip(); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java new file mode 100644 index 00000000..4193bafa --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.lease; + +import java.util.Objects; +import java.util.Optional; + +/** Immutable view of the controller-owned control lease. */ +public record WorkbenchControlLeaseSnapshot( + WorkbenchLeaseHolder holder, + String agentDisplayName, + String currentAction, + boolean uiAttached, + WorkbenchPermissionRequest pendingPermission +) { + public WorkbenchControlLeaseSnapshot { + Objects.requireNonNull(holder, "holder"); + agentDisplayName = agentDisplayName == null ? "" : agentDisplayName; + currentAction = currentAction == null ? "" : currentAction; + } + + public boolean agentHolds() { + return holder == WorkbenchLeaseHolder.AGENT; + } + + public boolean humanHolds() { + return holder == WorkbenchLeaseHolder.HUMAN; + } + + public Optional pending() { + return Optional.ofNullable(pendingPermission); + } + + public String bannerText() { + if (!agentHolds()) return ""; + String name = agentDisplayName.isBlank() ? "AI agent" : agentDisplayName; + if (currentAction.isBlank()) { + return name + " is in control of Workbench."; + } + return name + " is in control — " + currentAction; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java new file mode 100644 index 00000000..1ae4f3cd --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java @@ -0,0 +1,7 @@ +package tools.dscode.workbench.lease; + +/** Who currently has the Workbench live-control floor. */ +public enum WorkbenchLeaseHolder { + HUMAN, + AGENT +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java new file mode 100644 index 00000000..0dc97b8a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java @@ -0,0 +1,12 @@ +package tools.dscode.workbench.lease; + +/** Raised when Take control (or release) aborts an in-flight agent permission wait. */ +public final class WorkbenchPermissionCancelledException extends IllegalStateException { + public WorkbenchPermissionCancelledException() { + super("The human took control before the permission request was answered."); + } + + public WorkbenchPermissionCancelledException(String message) { + super(message); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java new file mode 100644 index 00000000..8c8f4149 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java @@ -0,0 +1,8 @@ +package tools.dscode.workbench.lease; + +/** Human answer to a pending agent permission request. */ +public enum WorkbenchPermissionDecision { + ALLOW, + DENY, + CANCELLED +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java new file mode 100644 index 00000000..3405e24a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java @@ -0,0 +1,6 @@ +package tools.dscode.workbench.lease; + +/** Gated actions that wait for an explicit Allow/Deny when a UI is attached. */ +public enum WorkbenchPermissionKind { + SAVE +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java new file mode 100644 index 00000000..bfcdfe4a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java @@ -0,0 +1,25 @@ +package tools.dscode.workbench.lease; + +import java.nio.file.Path; +import java.util.Objects; + +/** One pending Allow/Deny request shown in the Workbench UI. */ +public record WorkbenchPermissionRequest( + String id, + WorkbenchPermissionKind kind, + String summary, + String featurePath, + String scenarioName +) { + public WorkbenchPermissionRequest { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(kind, "kind"); + summary = summary == null ? "" : summary; + featurePath = featurePath == null ? "" : featurePath; + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public Path originFile() { + return featurePath.isBlank() ? null : Path.of(featurePath); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java new file mode 100644 index 00000000..b28ca6d1 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java @@ -0,0 +1,231 @@ +package tools.dscode.workbench.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import tools.dscode.workbench.WorkbenchServices; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Localhost-only JSON facade over the same {@link WorkbenchMcpTools} used by + * stdio MCP. UI mode cannot share process stdout with stdio MCP, so an agent + * attaches to the visible Workbench through this endpoint. + */ +public final class WorkbenchAttachServer implements AutoCloseable { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final WorkbenchServices services; + private final Path projectRoot; + private final Path stateFile; + private final String token; + private final HttpServer http; + private final WorkbenchMcpTools tools; + private final ExecutorService executor; + private final AtomicBoolean closed = new AtomicBoolean(); + + private WorkbenchAttachServer( + WorkbenchServices services, + Path projectRoot, + Path stateFile, + String token, + HttpServer http, + WorkbenchMcpTools tools, + ExecutorService executor + ) { + this.services = services; + this.projectRoot = projectRoot; + this.stateFile = stateFile; + this.token = token; + this.http = http; + this.tools = tools; + this.executor = executor; + } + + public static WorkbenchAttachServer start(WorkbenchServices services, Path projectRoot) { + Objects.requireNonNull(services, "services"); + Path root = projectRoot.toAbsolutePath().normalize(); + try { + HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + String token = newToken(); + WorkbenchMcpTools tools = new WorkbenchMcpTools(services, JSON); + Path stateFile = attachStateFile(root); + ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "pickleball-workbench-attach"); + thread.setDaemon(true); + return thread; + }); + WorkbenchAttachServer server = new WorkbenchAttachServer( + services, root, stateFile, token, http, tools, executor + ); + http.createContext("/health", server::health); + http.createContext("/lease", server::lease); + http.createContext("/player", server::player); + http.createContext("/tools", server::tools); + http.setExecutor(executor); + http.start(); + server.writeStateFile(); + return server; + } catch (IOException failure) { + throw new IllegalStateException("Could not start the Workbench agent-attach endpoint.", failure); + } + } + + public static Path attachStateFile(Path projectRoot) { + return projectRoot.resolve(".pickleball").resolve("workbench").resolve("attach.json"); + } + + public String url() { + return "http://127.0.0.1:" + http.getAddress().getPort(); + } + + public String token() { + return token; + } + + public Path stateFile() { + return stateFile; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + http.stop(0); + executor.shutdownNow(); + try { + Files.deleteIfExists(stateFile); + } catch (IOException ignored) { + // Disposable attach state. + } + } + + private void health(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, Map.of( + "status", "ok", + "url", url(), + "pid", ProcessHandle.current().pid() + )); + } + + private void lease(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, services.controlLeaseSnapshot()); + } + + private void player(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, services.playerState()); + } + + private void tools(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + String path = exchange.getRequestURI().getPath(); + if ("/tools".equals(path) && "GET".equals(exchange.getRequestMethod())) { + send(exchange, 200, Map.of("tools", tools.names())); + return; + } + if (!path.startsWith("/tools/") || path.length() <= "/tools/".length()) { + send(exchange, 404, Map.of("error", "Unknown attach path")); + return; + } + if (!"POST".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "POST a JSON argument object to invoke a tool")); + return; + } + String name = path.substring("/tools/".length()); + Map arguments = readJsonObject(exchange.getRequestBody()); + try { + Object value = tools.call(name, arguments); + send(exchange, 200, value); + } catch (RuntimeException failure) { + send(exchange, 400, Map.of( + "error", failure.getClass().getSimpleName(), + "message", failure.getMessage() == null ? failure.toString() : failure.getMessage() + )); + } + } + + private boolean authorized(HttpExchange exchange) throws IOException { + String header = firstHeader(exchange.getRequestHeaders(), "Authorization"); + String tokenHeader = firstHeader(exchange.getRequestHeaders(), "X-Workbench-Token"); + String presented = tokenHeader; + if (header != null && header.regionMatches(true, 0, "Bearer ", 0, 7)) { + presented = header.substring(7).strip(); + } + if (token.equals(presented)) return true; + send(exchange, 401, Map.of("error", "Missing or invalid Workbench attach token")); + return false; + } + + private void writeStateFile() throws IOException { + Files.createDirectories(stateFile.getParent()); + Map payload = new LinkedHashMap<>(); + payload.put("url", url()); + payload.put("token", token); + payload.put("pid", ProcessHandle.current().pid()); + payload.put("project", projectRoot.toString()); + payload.put("mode", "ui-attach"); + payload.put("bind", "127.0.0.1"); + Files.writeString(stateFile, JSON.writerWithDefaultPrettyPrinter().writeValueAsString(payload)); + } + + private static String firstHeader(Headers headers, String name) { + if (headers == null) return null; + String value = headers.getFirst(name); + return value == null || value.isBlank() ? null : value.strip(); + } + + @SuppressWarnings("unchecked") + private static Map readJsonObject(InputStream input) throws IOException { + byte[] bytes = input.readAllBytes(); + if (bytes.length == 0) return Map.of(); + Object value = JSON.readValue(bytes, Object.class); + if (value instanceof Map map) { + return (Map) map; + } + throw new IllegalArgumentException("Tool arguments must be a JSON object."); + } + + private static void send(HttpExchange exchange, int status, Object body) throws IOException { + byte[] bytes = JSON.writeValueAsBytes(body); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static String newToken() { + byte[] bytes = new byte[24]; + RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index e928818e..f8515aee 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java @@ -5,6 +5,8 @@ import io.modelcontextprotocol.spec.McpSchema; import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -16,69 +18,124 @@ final class WorkbenchMcpTools { private final WorkbenchServices services; private final ObjectMapper json; + private final Map tools = new LinkedHashMap<>(); WorkbenchMcpTools(WorkbenchServices services, ObjectMapper json) { this.services = services; this.json = json; + register(); } List specifications() { - List tools = new ArrayList<>(); - - tools.add(tool("workbench_sync", "Synchronize the selected consumer project.", schema(Map.of()), - args -> services.synchronize())); - tools.add(tool("workbench_sync_status", "Read the current Workbench synchronization manifest.", schema(Map.of()), - args -> services.synchronizationStatus())); - tools.add(tool("workbench_worker_start", "Start the persistent interactive consumer worker.", schema(Map.of()), - args -> services.startWorker())); - tools.add(tool("workbench_worker_restart", "Restart the worker in a fresh JVM without rebuilding.", schema(Map.of()), - args -> services.restartWorker())); - tools.add(tool("workbench_worker_stop", "Stop the interactive worker cleanly.", schema(Map.of()), - args -> services.stopWorker())); - tools.add(tool("workbench_worker_status", "Read the current interactive worker status.", schema(Map.of()), - args -> services.workerStatus())); - - tools.add(tool("workbench_execute_step", "Execute raw Gherkin in the paused live scenario.", + List specifications = new ArrayList<>(); + for (ToolBinding binding : tools.values()) { + specifications.add(specification(binding)); + } + return List.copyOf(specifications); + } + + List names() { + return List.copyOf(tools.keySet()); + } + + Object call(String name, Map arguments) { + ToolBinding binding = tools.get(name); + if (binding == null) { + throw new IllegalArgumentException("Unknown Workbench tool: " + name); + } + return WorkbenchCallContext.callAs( + WorkbenchLeaseHolder.AGENT, + () -> binding.action.apply(arguments == null ? Map.of() : arguments) + ); + } + + private void register() { + add("workbench_sync", "Synchronize the selected consumer project.", schema(Map.of()), + args -> services.synchronize()); + add("workbench_sync_status", "Read the current Workbench synchronization manifest.", schema(Map.of()), + args -> services.synchronizationStatus()); + add("workbench_worker_start", "Start the persistent interactive consumer worker.", schema(Map.of()), + args -> services.startWorker()); + add("workbench_worker_restart", "Restart the worker in a fresh JVM without rebuilding.", schema(Map.of()), + args -> services.restartWorker()); + add("workbench_worker_stop", "Stop the interactive worker cleanly.", schema(Map.of()), + args -> services.stopWorker()); + add("workbench_worker_status", "Read the current interactive worker status.", schema(Map.of()), + args -> services.workerStatus()); + + add("workbench_request_control", + "Request the Workbench live-control lease so this agent can test the live scenario while the human watches.", + schema(Map.of("agentName", stringProperty("Display name shown in the Workbench banner.")), "agentName"), + args -> services.requestControl(text(args, "agentName"))); + add("workbench_release_control", + "Release the Workbench live-control lease back to the human.", + schema(Map.of()), + args -> services.releaseControl()); + add("workbench_set_current_action", + "Update the watched-agent banner with what this agent is currently doing.", + schema(Map.of("text", stringProperty("Short action text shown in the UI banner.")), "text"), + args -> services.setCurrentAction(text(args, "text"))); + add("workbench_control_lease", + "Read the current Workbench control-lease holder, banner action, and pending permission.", + schema(Map.of()), + args -> services.controlLeaseSnapshot()); + add("workbench_player_state", + "Read the shared live scenario buffer, playhead, player state, and originating feature path if any.", + schema(Map.of()), + args -> services.playerState()); + add("workbench_player_replace_document", + "Replace the live session buffer. Does not write the original .feature file.", + schema(Map.of("text", stringProperty("Full live Gherkin document.")), "text"), + args -> { + services.replaceLiveDocument(text(args, "text").lines().toList()); + return services.playerState(); + }); + add("workbench_request_save", + "Ask to copy the live scenario into the original .feature file. With a UI attached this waits for Allow/Deny and never writes on deny.", + schema(Map.of()), + args -> services.requestSave()); + + add("workbench_execute_step", "Execute raw Gherkin in the paused live scenario.", schema(Map.of( "text", stringProperty("Gherkin step text."), "argument", stringProperty("Optional DocString-style argument text.") ), "text"), - args -> services.executeStep(text(args, "text"), optionalText(args, "argument")))); + args -> services.executeStep(text(args, "text"), optionalText(args, "argument"))); - tools.add(tool("workbench_mapping_get", "Read one value from a Pickleball Mapping.", + add("workbench_mapping_get", "Read one value from a Pickleball Mapping.", schema(Map.of( "mapReference", stringProperty("Mapping reference."), "key", stringProperty("Mapping key.") ), "mapReference", "key"), - args -> services.mappingGet(text(args, "mapReference"), text(args, "key")))); - tools.add(tool("workbench_mapping_put", "Write one value into a Pickleball Mapping.", + args -> services.mappingGet(text(args, "mapReference"), text(args, "key"))); + add("workbench_mapping_put", "Write one value into a Pickleball Mapping.", schema(Map.of( "mapReference", stringProperty("Mapping reference."), "key", stringProperty("Mapping key."), "value", Map.of("description", "JSON-compatible value to store.") ), "mapReference", "key", "value"), - args -> services.mappingPut(text(args, "mapReference"), text(args, "key"), args.get("value")))); - tools.add(tool("workbench_mapping_resolve", "Resolve Pickleball mapping/template references in text.", + args -> services.mappingPut(text(args, "mapReference"), text(args, "key"), args.get("value"))); + add("workbench_mapping_resolve", "Resolve Pickleball mapping/template references in text.", schema(Map.of("input", stringProperty("Text to resolve.")), "input"), - args -> services.mappingResolve(text(args, "input")))); - tools.add(tool("workbench_mapping_snapshot", "Snapshot one Mapping for later restoration.", + args -> services.mappingResolve(text(args, "input"))); + add("workbench_mapping_snapshot", "Snapshot one Mapping for later restoration.", schema(Map.of("mapReference", stringProperty("Mapping reference.")), "mapReference"), - args -> services.mappingSnapshot(text(args, "mapReference")))); - tools.add(tool("workbench_mapping_restore", "Restore a previously returned Mapping snapshot.", + args -> services.mappingSnapshot(text(args, "mapReference"))); + add("workbench_mapping_restore", "Restore a previously returned Mapping snapshot.", schema(Map.of("snapshot", objectProperty("Snapshot returned by workbench_mapping_snapshot.")), "snapshot"), - args -> services.mappingRestore(json.convertValue(args.get("snapshot"), ControlBridgeMappingSnapshot.class)))); + args -> services.mappingRestore(json.convertValue(args.get("snapshot"), ControlBridgeMappingSnapshot.class))); - tools.add(tool("workbench_events", "Read semantic runtime events for the active scenario.", + add("workbench_events", "Read semantic runtime events for the active scenario.", schema(Map.of( "afterSequence", integerProperty("Return events after this sequence number."), "limit", integerProperty("Maximum events to return.") )), - args -> services.events(longValue(args, "afterSequence"), integer(args, "limit")))); - tools.add(tool("workbench_browser_page", "Read current browser page evidence.", schema(Map.of()), - args -> services.browserPage())); - tools.add(tool("workbench_browser_screenshot", "Capture current browser screenshot evidence.", schema(Map.of()), - args -> services.browserScreenshot())); - tools.add(tool("workbench_element_inspect", "Inspect browser elements using Pickleball element vocabulary.", + args -> services.events(longValue(args, "afterSequence"), integer(args, "limit"))); + add("workbench_browser_page", "Read current browser page evidence.", schema(Map.of()), + args -> services.browserPage()); + add("workbench_browser_screenshot", "Capture current browser screenshot evidence.", schema(Map.of()), + args -> services.browserScreenshot()); + add("workbench_element_inspect", "Inspect browser elements using Pickleball element vocabulary.", schema(Map.of( "category", stringProperty("Optional Pickleball element category."), "text", stringProperty("Optional visible/context text."), @@ -90,14 +147,14 @@ List specifications() { optionalText(args, "text"), optionalText(args, "operation"), integer(args, "maxElements") - ))); - tools.add(tool("workbench_service_call", "Execute an existing Pickleball service-call selector and return evidence.", + )); + add("workbench_service_call", "Execute an existing Pickleball service-call selector and return evidence.", schema(Map.of("selector", stringProperty("Existing service-call selector, for example %health-full-url.")), "selector"), - args -> services.serviceCall(text(args, "selector")))); + args -> services.serviceCall(text(args, "selector"))); - tools.add(tool("workbench_breakpoint_list", "List semantic breakpoints.", schema(Map.of()), - args -> services.breakpoints())); - tools.add(tool("workbench_breakpoint_add", "Add a semantic runtime breakpoint.", + add("workbench_breakpoint_list", "List semantic breakpoints.", schema(Map.of()), + args -> services.breakpoints()); + add("workbench_breakpoint_add", "Add a semantic runtime breakpoint.", schema(Map.of( "hook", stringProperty("Control hook name, for example BEFORE_STEP."), "signatureContains", stringProperty("Optional signature substring filter."), @@ -113,16 +170,16 @@ List specifications() { optionalText(args, "phraseContains"), bool(args, "oneShot", false), integer(args, "leaseSeconds") - ))); - tools.add(tool("workbench_breakpoint_remove", "Remove one semantic breakpoint.", + )); + add("workbench_breakpoint_remove", "Remove one semantic breakpoint.", schema(Map.of("breakpointId", stringProperty("Breakpoint id.")), "breakpointId"), - args -> Map.of("removed", services.removeBreakpoint(text(args, "breakpointId"))))); - tools.add(tool("workbench_breakpoint_clear", "Clear all semantic breakpoints.", schema(Map.of()), - args -> Map.of("removed", services.clearBreakpoints()))); + args -> Map.of("removed", services.removeBreakpoint(text(args, "breakpointId")))); + add("workbench_breakpoint_clear", "Clear all semantic breakpoints.", schema(Map.of()), + args -> Map.of("removed", services.clearBreakpoints())); - tools.add(tool("workbench_step_override_list", "List Step Overrides in the active live scenario.", schema(Map.of()), - args -> services.stepOverrides())); - tools.add(tool("workbench_step_override_compile", "Compile and install a scenario-scoped REPLACE/REGEX Step Override in the worker.", + add("workbench_step_override_list", "List Step Overrides in the active live scenario.", schema(Map.of()), + args -> services.stepOverrides()); + add("workbench_step_override_compile", "Compile and install a scenario-scoped REPLACE/REGEX Step Override in the worker.", schema(Map.of( "id", stringProperty("Stable override id; recompiling the id replaces it."), "regex", stringProperty("Regular expression matched before ordinary Cucumber glue."), @@ -130,37 +187,36 @@ List specifications() { ), "id", "regex", "source"), args -> services.compileStepOverride( text(args, "id"), text(args, "regex"), text(args, "source") - ))); - tools.add(tool("workbench_step_override_remove", "Remove one Step Override from the active scenario.", + )); + add("workbench_step_override_remove", "Remove one Step Override from the active scenario.", schema(Map.of("id", stringProperty("Override id.")), "id"), - args -> Map.of("removed", services.removeStepOverride(text(args, "id"))))); - tools.add(tool("workbench_step_override_clear", "Clear all Step Overrides from the active scenario.", schema(Map.of()), - args -> Map.of("removed", services.clearStepOverrides()))); - - return List.copyOf(tools); + args -> Map.of("removed", services.removeStepOverride(text(args, "id")))); + add("workbench_step_override_clear", "Clear all Step Overrides from the active scenario.", schema(Map.of()), + args -> Map.of("removed", services.clearStepOverrides())); } - private McpServerFeatures.SyncToolSpecification tool( + private void add( String name, String description, Map inputSchema, Function, Object> action ) { - McpSchema.Tool tool = McpSchema.Tool.builder(name, inputSchema) - .description(description) + tools.put(name, new ToolBinding(name, description, inputSchema, action)); + } + + private McpServerFeatures.SyncToolSpecification specification(ToolBinding binding) { + McpSchema.Tool tool = McpSchema.Tool.builder(binding.name, binding.inputSchema) + .description(binding.description) .build(); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) - .callHandler((exchange, request) -> invoke(action, request.arguments())) + .callHandler((exchange, request) -> invoke(binding.name, request.arguments())) .build(); } - private McpSchema.CallToolResult invoke( - Function, Object> action, - Map arguments - ) { + private McpSchema.CallToolResult invoke(String name, Map arguments) { try { - Object value = action.apply(arguments == null ? Map.of() : arguments); + Object value = call(name, arguments); return result(value, false); } catch (RuntimeException failure) { return result(Map.of( @@ -234,4 +290,12 @@ private static boolean bool(Map args, String name, boolean defau Object value = args.get(name); return value instanceof Boolean bool ? bool : defaultValue; } + + private record ToolBinding( + String name, + String description, + Map inputSchema, + Function, Object> action + ) { + } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java new file mode 100644 index 00000000..b959e3c7 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java @@ -0,0 +1,119 @@ +package tools.dscode.workbench.player; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Copies the live session buffer into the originating scenario of the original + * {@code .feature} file. Demo buffers have no save path. Picker load never writes. + */ +public final class LiveFeatureSave { + private LiveFeatureSave() { + } + + public static WorkbenchSavePreview preview(LivePlaybackCoordinator playback) { + Objects.requireNonNull(playback, "playback"); + ScenarioOrigin origin = playback.origin(); + if (!origin.savable()) { + return WorkbenchSavePreview.unsavable( + "The default demo is session-only and has no original .feature file to write." + ); + } + List body = scenarioBody(playback.player().documentText()); + String fileName = origin.file().getFileName().toString(); + String scenario = origin.scenarioName().isBlank() ? "(unnamed scenario)" : origin.scenarioName(); + return new WorkbenchSavePreview( + true, + origin.file(), + origin.scenarioName(), + "Copy these live steps into file " + fileName + " / scenario " + scenario + "?", + body + ); + } + + public static WorkbenchSaveResult write(LivePlaybackCoordinator playback) { + WorkbenchSavePreview preview = preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + Path file = preview.featurePath(); + ScenarioOrigin origin = playback.origin(); + try { + List original = Files.exists(file) + ? Files.readAllLines(file, StandardCharsets.UTF_8) + : new ArrayList<>(); + List replacement = preview.liveScenarioLines(); + List rewritten = splice(original, origin.startLine(), origin.endLine(), replacement); + String newline = detectNewline(file); + Files.writeString(file, join(rewritten, newline), StandardCharsets.UTF_8); + int newEnd = origin.startLine() + replacement.size() - 1; + playback.updateOrigin(origin.withEndLine(Math.max(origin.startLine(), newEnd))); + return WorkbenchSaveResult.written(file, origin.scenarioName()); + } catch (IOException failure) { + throw new IllegalStateException("Could not write the originating feature file: " + file, failure); + } + } + + static List scenarioBody(String documentText) { + List lines = splitPreserve(documentText); + int start = 0; + for (int i = 0; i < lines.size(); i++) { + String trimmed = lines.get(i).strip(); + if (trimmed.startsWith("Scenario:") || trimmed.startsWith("Scenario Outline:")) { + start = i; + break; + } + } + List body = new ArrayList<>(lines.subList(start, lines.size())); + while (!body.isEmpty() && body.getLast().isBlank()) { + body.removeLast(); + } + return body; + } + + static List splice(List original, int startLine, int endLine, List replacement) { + List rewritten = new ArrayList<>(); + int start = Math.max(1, startLine); + int end = Math.max(start, endLine); + int size = original.size(); + for (int i = 1; i < start && i <= size; i++) { + rewritten.add(original.get(i - 1)); + } + rewritten.addAll(replacement); + for (int i = end + 1; i <= size; i++) { + rewritten.add(original.get(i - 1)); + } + return rewritten; + } + + private static List splitPreserve(String documentText) { + if (documentText == null || documentText.isEmpty()) return new ArrayList<>(); + String normalized = documentText.replace("\r\n", "\n").replace('\r', '\n'); + String[] parts = normalized.split("\n", -1); + List lines = new ArrayList<>(parts.length); + for (String part : parts) { + lines.add(part); + } + if (!lines.isEmpty() && lines.getLast().isEmpty()) { + lines.removeLast(); + } + return lines; + } + + private static String detectNewline(Path file) throws IOException { + if (!Files.exists(file)) return System.lineSeparator(); + String raw = Files.readString(file, StandardCharsets.UTF_8); + if (raw.contains("\r\n")) return "\r\n"; + if (raw.contains("\n")) return "\n"; + return System.lineSeparator(); + } + + private static String join(List lines, String newline) { + return String.join(newline, lines) + newline; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java index e5c263cc..d6912749 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java @@ -11,7 +11,7 @@ */ public final class LivePlaybackCoordinator { private final LiveScenarioPlayer player; - private java.nio.file.Path originFile; + private ScenarioOrigin origin = ScenarioOrigin.none(); public LivePlaybackCoordinator(LiveScenarioPlayer player) { this.player = Objects.requireNonNull(player, "player"); @@ -21,21 +21,41 @@ public LiveScenarioPlayer player() { return player; } + public ScenarioOrigin origin() { + return origin; + } + public Optional originFile() { - return Optional.ofNullable(originFile); + return origin.originFile(); } public void clearOrigin() { - originFile = null; + origin = ScenarioOrigin.none(); + } + + public void updateOrigin(ScenarioOrigin origin) { + this.origin = origin == null ? ScenarioOrigin.none() : origin; } public void loadDefaultDemo() { - originFile = null; + origin = ScenarioOrigin.none(); player.loadDocument(LiveScenarioPlayer.DEFAULT_DEMO_SCENARIO); } - public void loadScenario(List lines, java.nio.file.Path origin) { - originFile = origin; + public void loadScenario(List lines, java.nio.file.Path originFile) { + loadScenario(lines, originFile, "", 0, 0); + } + + public void loadScenario( + List lines, + java.nio.file.Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + origin = originFile == null + ? ScenarioOrigin.none() + : new ScenarioOrigin(originFile, scenarioName, startLine, endLine); player.loadDocument(lines); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java new file mode 100644 index 00000000..569dad6d --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java @@ -0,0 +1,33 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +/** Origin of a picker-loaded live buffer. Demo sessions have no save path. */ +public record ScenarioOrigin( + Path file, + String scenarioName, + int startLine, + int endLine +) { + public ScenarioOrigin { + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public static ScenarioOrigin none() { + return new ScenarioOrigin(null, "", 0, 0); + } + + public boolean savable() { + return file != null; + } + + public Optional originFile() { + return Optional.ofNullable(file); + } + + public ScenarioOrigin withEndLine(int newEndLine) { + return new ScenarioOrigin(file, scenarioName, startLine, newEndLine); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java new file mode 100644 index 00000000..adb9df31 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java @@ -0,0 +1,33 @@ +package tools.dscode.workbench.player; + +import tools.dscode.workbench.player.LiveScenarioPlayer.State; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Read-only live-buffer snapshot for MCP/HTTP attach clients. */ +public record WorkbenchPlayerState( + String documentText, + List lines, + State playerState, + Long playheadId, + String playheadText, + Long selectedId, + String sourceFeaturePath, + String scenarioName, + boolean savable +) { + public WorkbenchPlayerState { + Objects.requireNonNull(playerState, "playerState"); + documentText = documentText == null ? "" : documentText; + lines = List.copyOf(lines == null ? List.of() : lines); + sourceFeaturePath = sourceFeaturePath == null ? "" : sourceFeaturePath; + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public Optional originFile() { + return sourceFeaturePath.isBlank() ? Optional.empty() : Optional.of(Path.of(sourceFeaturePath)); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java new file mode 100644 index 00000000..2ba441ea --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java @@ -0,0 +1,29 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Preview of a gated Save into the originating scenario. */ +public record WorkbenchSavePreview( + boolean savable, + Path featurePath, + String scenarioName, + String summary, + List liveScenarioLines +) { + public WorkbenchSavePreview { + scenarioName = scenarioName == null ? "" : scenarioName; + summary = summary == null ? "" : summary; + liveScenarioLines = List.copyOf(liveScenarioLines == null ? List.of() : liveScenarioLines); + } + + public Optional originFile() { + return Optional.ofNullable(featurePath); + } + + public static WorkbenchSavePreview unsavable(String reason) { + return new WorkbenchSavePreview(false, null, "", reason, List.of()); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java new file mode 100644 index 00000000..6480f156 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java @@ -0,0 +1,42 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.Objects; + +/** Outcome of an explicit live-buffer Save. Deny/cancel never writes. */ +public record WorkbenchSaveResult( + boolean written, + String status, + String featurePath, + String scenarioName, + String message +) { + public WorkbenchSaveResult { + Objects.requireNonNull(status, "status"); + featurePath = featurePath == null ? "" : featurePath; + scenarioName = scenarioName == null ? "" : scenarioName; + message = message == null ? "" : message; + } + + public static WorkbenchSaveResult written(Path featurePath, String scenarioName) { + return new WorkbenchSaveResult( + true, + "WRITTEN", + featurePath.toString(), + scenarioName, + "Copied the live scenario into " + featurePath.getFileName() + " / " + scenarioName + "." + ); + } + + public static WorkbenchSaveResult denied() { + return new WorkbenchSaveResult(false, "DENIED", "", "", "Save was denied. The original feature file was not changed."); + } + + public static WorkbenchSaveResult cancelled(String message) { + return new WorkbenchSaveResult(false, "CANCELLED", "", "", message); + } + + public static WorkbenchSaveResult unsavable(String message) { + return new WorkbenchSaveResult(false, "UNSAVABLE", "", "", message); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java index d0f7aad3..20706e5b 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java @@ -43,6 +43,7 @@ final class FeaturePickerPanel extends JPanel { private Consumer onScenario; private Runnable onSave; private boolean syncing; + private boolean locked; FeaturePickerPanel() { super(new BorderLayout(8, 8)); @@ -79,7 +80,7 @@ final class FeaturePickerPanel extends JPanel { featureList.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent event) { - if (catalog == null) return; + if (catalog == null || locked) return; int index = featureList.locationToIndex(event.getPoint()); if (index < 0) return; ConsumerFeatureCatalog.FeatureEntry feature = featureModel.get(index); @@ -94,7 +95,7 @@ public void mousePressed(MouseEvent event) { scenarioList.addListSelectionListener(event -> { if (event.getValueIsAdjusting()) return; ConsumerFeatureCatalog.ScenarioEntry selected = scenarioList.getSelectedValue(); - if (selected != null && onScenario != null) onScenario.accept(selected); + if (selected != null && onScenario != null && !locked) onScenario.accept(selected); }); search.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent event) { filterChanged(); } @@ -103,7 +104,7 @@ public void mousePressed(MouseEvent event) { }); saveButton.setEnabled(false); saveButton.addActionListener(event -> { - if (onSave != null) onSave.run(); + if (onSave != null && !locked) onSave.run(); }); } @@ -146,8 +147,21 @@ void onSave(Runnable onSave) { this.onSave = onSave; } + private boolean saveEnabled; + void setSaveEnabled(boolean enabled) { - saveButton.setEnabled(enabled); + saveEnabled = enabled; + saveButton.setEnabled(enabled && !locked); + } + + void setLocked(boolean locked) { + this.locked = locked; + saveButton.setEnabled(saveEnabled && !locked); + featureList.setEnabled(!locked); + scenarioList.setEnabled(!locked); + search.setEnabled(!locked); + featureNameMode.setEnabled(!locked); + filePathMode.setEnabled(!locked); } private JPanel header() { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index dd01a8fa..0b891742 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -5,10 +5,15 @@ import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; import tools.dscode.workbench.diagnostics.DiagnosticEvidenceNavigator; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.lease.WorkbenchPermissionRequest; import tools.dscode.workbench.mapping.MappingTreeModel; import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.mcp.WorkbenchAttachServer; import tools.dscode.workbench.player.LivePlaybackCoordinator; import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.ui.web.DiagnosticExplorerHost; import tools.dscode.workbench.ui.web.GherkinEditorHost; @@ -47,8 +52,9 @@ final class WorkbenchFrame extends JFrame { private static final int MAPPING_SAVE_DELAY_MS = 650; private final WorkbenchUiController controller; - private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); - private final LivePlaybackCoordinator playback = new LivePlaybackCoordinator(player); + private final WorkbenchAttachServer attach; + private final LiveScenarioPlayer player; + private final LivePlaybackCoordinator playback; private final ObjectMapper json = new ObjectMapper(); private final FeaturePickerPanel picker = new FeaturePickerPanel(); private final TerminalPanel terminal = new TerminalPanel(); @@ -82,6 +88,18 @@ final class WorkbenchFrame extends JFrame { private final JLabel readinessLabel = new JLabel("Loading status..."); private final JLabel playerStatusLabel = new JLabel("Stopped"); private final JLabel activityLabel = new JLabel("Ready"); + private final JPanel agentBanner = new JPanel(new BorderLayout(12, 0)); + private final JLabel agentBannerLabel = new JLabel(); + private final JButton takeControlButton = WorkbenchTheme.accentButton( + "Take control", + "Return live Workbench controls to the human and cancel in-flight agent permission waits" + ); + private final JPanel permissionBar = new JPanel(new BorderLayout(12, 0)); + private final JLabel permissionLabel = new JLabel(); + private final JButton allowButton = WorkbenchTheme.accentButton("Allow", "Allow the agent to copy the live scenario into the original feature file"); + private final JButton denyButton = WorkbenchTheme.flatButton("Deny", "Deny the write; the original feature file is left unchanged"); + private WorkbenchControlLeaseSnapshot lastLease; + private String pendingPermissionId; private final JMenuItem syncItem = new JMenuItem("Synchronize"); private final JMenuItem refreshItem = new JMenuItem("Refresh Status"); @@ -117,8 +135,15 @@ final class WorkbenchFrame extends JFrame { private boolean closing; WorkbenchFrame(WorkbenchUiController controller) { + this(controller, null); + } + + WorkbenchFrame(WorkbenchUiController controller, WorkbenchAttachServer attach) { super("Pickleball Workbench"); this.controller = controller; + this.attach = attach; + this.player = controller.player(); + this.playback = controller.playback(); mappingSaveTimer.setRepeats(false); WorkbenchTheme.install(); @@ -134,7 +159,7 @@ final class WorkbenchFrame extends JFrame { JPanel root = new JPanel(new BorderLayout(10, 10)); root.setBackground(WorkbenchTheme.BACKGROUND); root.setBorder(new EmptyBorder(10, 12, 10, 12)); - root.add(playerBar(), BorderLayout.NORTH); + root.add(topChrome(), BorderLayout.NORTH); JSplitPane editorAndRight = new JSplitPane( JSplitPane.HORIZONTAL_SPLIT, @@ -169,6 +194,14 @@ final class WorkbenchFrame extends JFrame { refreshFeatureCatalog(); terminal.start(); + configureAgentChrome(); + controller.addLeaseListener(snapshot -> SwingUtilities.invokeLater(() -> applyLease(snapshot))); + controller.addPlayerListener(() -> SwingUtilities.invokeLater(() -> { + syncScenarioView(); + updatePlayerView(null); + })); + applyLease(controller.controlLease()); + addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent event) { @@ -206,6 +239,57 @@ private JMenuBar menuBar() { return bar; } + private JComponent topChrome() { + JPanel north = new JPanel(); + north.setOpaque(false); + north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS)); + north.add(agentBanner); + north.add(Box.createVerticalStrut(6)); + north.add(permissionBar); + north.add(Box.createVerticalStrut(6)); + north.add(playerBar()); + return north; + } + + private void configureAgentChrome() { + agentBanner.setBackground(new Color(0xFE, 0xF3, 0xC7)); + agentBanner.setBorder(WorkbenchTheme.cardBorder()); + agentBannerLabel.setForeground(WorkbenchTheme.TEXT); + agentBannerLabel.setFont(agentBannerLabel.getFont().deriveFont(Font.BOLD, 13f)); + takeControlButton.addActionListener(event -> { + controller.takeControl(); + applyLease(controller.controlLease()); + updatePlayerView("You took control of Workbench."); + }); + agentBanner.add(agentBannerLabel, BorderLayout.CENTER); + agentBanner.add(takeControlButton, BorderLayout.EAST); + agentBanner.setVisible(false); + + permissionBar.setBackground(new Color(0xDB, 0xEA, 0xFE)); + permissionBar.setBorder(WorkbenchTheme.cardBorder()); + permissionLabel.setForeground(WorkbenchTheme.TEXT); + JPanel permissionButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + permissionButtons.setOpaque(false); + allowButton.addActionListener(event -> answerPermission(true)); + denyButton.addActionListener(event -> answerPermission(false)); + permissionButtons.add(allowButton); + permissionButtons.add(denyButton); + permissionBar.add(permissionLabel, BorderLayout.CENTER); + permissionBar.add(permissionButtons, BorderLayout.EAST); + permissionBar.setVisible(false); + } + + private void answerPermission(boolean allow) { + if (pendingPermissionId == null) return; + String id = pendingPermissionId; + pendingPermissionId = null; + controller.answerPermission(id, allow); + applyLease(controller.controlLease()); + updatePlayerView(allow + ? "Allowed the agent Save request." + : "Denied the agent Save request. The original feature file was not changed."); + } + private JPanel playerBar() { JPanel bar = new JPanel(new BorderLayout(12, 0)); bar.setBackground(WorkbenchTheme.SURFACE); @@ -413,6 +497,7 @@ private void configureWebViews() { try { gherkinHost.onDocument(this::applyEditorLines); gherkinHost.onSeek(id -> { + if (humanControlsLocked()) return; player.clickLine(id); updateFromHereAvailability(); refreshPlayheadHighlight(); @@ -459,7 +544,13 @@ private void configureWebViews() { private void configurePicker() { picker.onScenarioSelected(scenario -> { - playback.loadScenario(scenario.lines(), scenario.file()); + controller.loadPickerScenario( + scenario.lines(), + scenario.file(), + scenario.name(), + scenario.startLine(), + scenario.endLine() + ); picker.setSaveEnabled(true); syncScenarioView(); updatePlayerView("Loaded " + scenario.displayLabel() + " into the live session buffer."); @@ -491,7 +582,7 @@ private void togglePicker() { } private void applyEditorLines(List lines) { - if (syncingScenarioDocument) return; + if (syncingScenarioDocument || humanControlsLocked()) return; player.replaceDocument(lines); updateFromHereAvailability(); if (player.state() == LiveScenarioPlayer.State.RUNNING) { @@ -505,7 +596,11 @@ private void applyEditorLines(List lines) { private void pushGherkinView() { if (gherkinView == null) return; - gherkinView.evalJsonCall("window.setEditorState", WorkbenchWebJson.editorState(player, executingStepId)); + gherkinView.evalJsonCall("window.setEditorState", WorkbenchWebJson.editorState( + player, + executingStepId, + humanControlsLocked() + )); } private void pushMappingView() { @@ -522,12 +617,14 @@ private void pushMappingView() { selected == null ? null : new WorkbenchWebJson.MapChoice( selected.reference(), selected.label(), selected.restorable()), mappingModel, - mappingStatus.getText() + mappingStatus.getText(), + humanControlsLocked() ) ); } private void applyMappingPropertyEdit(MappingEditorHost.PropertyEdit edit) { + if (humanControlsLocked()) return; if (lastState == null || !lastState.liveReady() || loadedMapping == null) return; try { if (edit.oldKey() == null || edit.oldKey().isBlank() || edit.oldKey().equals(edit.key())) { @@ -570,15 +667,31 @@ private void applyMappingPropertyEdit(MappingEditorHost.PropertyEdit edit) { } private void saveLoadedFeature() { - Path origin = playback.originFile().orElse(null); - if (origin == null) { - showFailure("Could not save", new IllegalStateException("The default demo is session-only.")); + if (humanControlsLocked()) return; + WorkbenchSavePreview preview = controller.savePreview(); + if (!preview.savable()) { + showFailure("Could not save", new IllegalStateException(preview.summary())); + return; + } + int choice = JOptionPane.showConfirmDialog( + this, + preview.summary() + "\n\nWorkbench will not write the original feature file unless you confirm.", + "Save live scenario", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.QUESTION_MESSAGE + ); + if (choice != JOptionPane.OK_OPTION) { + updatePlayerView("Save cancelled. The original feature file was not changed."); return; } try { - Files.writeString(origin, player.documentText() + System.lineSeparator()); - updatePlayerView("Saved live buffer to " + origin.getFileName() + "."); - } catch (Exception failure) { + WorkbenchSaveResult result = controller.commitSave(); + if (result.written()) { + updatePlayerView(result.message()); + } else { + showFailure("Could not save", new IllegalStateException(result.message())); + } + } catch (RuntimeException failure) { showFailure("Could not save feature file", failure); } } @@ -679,12 +792,14 @@ public void changedUpdate(DocumentEvent event) { private void wirePlayerActions() { playButton.addActionListener(event -> runScenarioFromBeginning()); pauseButton.addActionListener(event -> { + if (humanControlsLocked()) return; player.pause(); updatePlayerView(playbackBusy ? "Pause requested; the current step will finish first." : "Scenario playback paused."); }); playerStopButton.addActionListener(event -> { + if (humanControlsLocked()) return; player.stop(); pendingFreshRun = false; pendingFreshRunStepId = null; @@ -729,6 +844,7 @@ private void runScenarioFromSelectedStep() { } private void requestFreshRun(Long startStepId) { + if (humanControlsLocked()) return; pendingIsolatedStep = null; if (playbackBusy || playbackPreparing) { pendingFreshRun = true; @@ -868,6 +984,7 @@ private void schedulePlaybackStep() { } private void insertStep() { + if (humanControlsLocked()) return; try { LiveScenarioPlayer.Line inserted = player.insertStep(stepText.getText()); stepText.setText(""); @@ -892,6 +1009,7 @@ private void insertStep() { } private void updateSelectedStep() { + if (humanControlsLocked()) return; try { LiveScenarioPlayer.Line updated = player.updateSelectedStep(stepText.getText()); syncScenarioView(); @@ -903,6 +1021,7 @@ private void updateSelectedStep() { } private void executeStepOnly() { + if (humanControlsLocked()) return; String text = stepText.getText(); if (text == null || text.isBlank()) { showFailure("Could not execute step", @@ -965,6 +1084,7 @@ private boolean runPendingIsolatedStep() { } private void mappingChanged() { + if (humanControlsLocked()) return; if (loadingMapping || loadedMapping == null || !loadedMapping.restorable()) return; if (player.state() == LiveScenarioPlayer.State.RUNNING || player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP) { @@ -1113,7 +1233,7 @@ private void setMappingEditor(String text, boolean editable) { } private void scenarioDocumentChanged() { - if (syncingScenarioDocument) return; + if (syncingScenarioDocument || humanControlsLocked()) return; player.replaceDocument(List.of(scenarioEditor.getText().split("\n", -1))); seekPlayheadToCaret(); updateFromHereAvailability(); @@ -1128,7 +1248,7 @@ private void scenarioDocumentChanged() { } private void seekPlayheadToCaret() { - if (syncingScenarioDocument) return; + if (syncingScenarioDocument || humanControlsLocked()) return; int lineIndex = lineIndexAtCaret(); List lines = player.lines(); if (lineIndex < 0 || lineIndex >= lines.size()) { @@ -1164,7 +1284,8 @@ private void syncScenarioView() { private void updateFromHereAvailability() { fromHereButton.setEnabled( - player.selectedLine() + !humanControlsLocked() + && player.selectedLine() .or(player::playheadLine) .map(LiveScenarioPlayer.Line::executable) .orElse(false) @@ -1282,6 +1403,59 @@ private void applyState(WorkbenchUiController.State state) { setMappingEditor("", false); mappingStatus.setText("Start the live worker to inspect Mapping."); } + if (humanControlsLocked()) { + applyLease(lastLease); + } + } + + private boolean humanControlsLocked() { + return lastLease != null && lastLease.agentHolds(); + } + + private void applyLease(WorkbenchControlLeaseSnapshot snapshot) { + lastLease = snapshot; + boolean locked = snapshot != null && snapshot.agentHolds(); + agentBanner.setVisible(locked); + if (locked) { + agentBannerLabel.setText(snapshot.bannerText()); + } + WorkbenchPermissionRequest pending = snapshot == null ? null : snapshot.pendingPermission(); + permissionBar.setVisible(pending != null); + if (pending != null) { + pendingPermissionId = pending.id(); + permissionLabel.setText(pending.summary()); + } else { + pendingPermissionId = null; + } + + picker.setLocked(locked); + scenarioEditor.setEditable(!locked); + stepText.setEditable(!locked); + playButton.setEnabled(!locked); + pauseButton.setEnabled(!locked); + playerStopButton.setEnabled(!locked); + stepOnlyButton.setEnabled(!locked); + takeControlButton.setEnabled(locked); + if (locked) { + fromHereButton.setEnabled(false); + syncItem.setEnabled(false); + startItem.setEnabled(false); + restartItem.setEnabled(false); + stopItem.setEnabled(false); + nodeMapSelector.setEnabled(false); + mappingEditor.setEnabled(false); + } else if (lastState != null) { + syncItem.setEnabled(!lastState.workerRunning()); + startItem.setEnabled(lastState.synchronizedProject() && !lastState.workerRunning()); + restartItem.setEnabled(lastState.workerRunning()); + stopItem.setEnabled(lastState.workerRunning()); + if (lastState.liveReady()) { + nodeMapSelector.setEnabled(nodeMapSelector.getItemCount() > 0); + } + } + updateFromHereAvailability(); + pushGherkinView(); + pushMappingView(); } private void runTask( @@ -1543,6 +1717,7 @@ private void closeWorkbench() { activityLabel.setText("Closing Workbench..."); runTask( () -> { + if (attach != null) attach.close(); controller.close(); return Boolean.TRUE; }, diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java index 04aa5239..c4689e05 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java @@ -1,6 +1,7 @@ package tools.dscode.workbench.ui; import tools.dscode.workbench.WorkbenchController; +import tools.dscode.workbench.mcp.WorkbenchAttachServer; import javax.swing.*; import java.lang.reflect.InvocationTargetException; @@ -14,11 +15,11 @@ private WorkbenchUi() { public static void launch(Path projectRoot) { Runnable launch = () -> { WorkbenchTheme.install(); - WorkbenchUiController controller = new WorkbenchUiController( - projectRoot, - new WorkbenchController(projectRoot) - ); - new WorkbenchFrame(controller).setVisible(true); + WorkbenchController services = new WorkbenchController(projectRoot); + services.attachUi(); + WorkbenchAttachServer attach = WorkbenchAttachServer.start(services, projectRoot); + WorkbenchUiController controller = new WorkbenchUiController(projectRoot, services); + new WorkbenchFrame(controller, attach).setVisible(true); }; if (SwingUtilities.isEventDispatchThread()) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java index 818e15dc..a48c30ff 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java @@ -21,7 +21,12 @@ import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -250,6 +255,52 @@ Path projectRoot() { return projectRoot; } + LiveScenarioPlayer player() { + return services.player(); + } + + LivePlaybackCoordinator playback() { + return services.playback(); + } + + WorkbenchControlLeaseSnapshot controlLease() { + return services.controlLeaseSnapshot(); + } + + WorkbenchControlLeaseSnapshot takeControl() { + return services.takeControl(); + } + + void answerPermission(String requestId, boolean allow) { + services.answerPermission(requestId, allow); + } + + WorkbenchSavePreview savePreview() { + return services.savePreview(); + } + + WorkbenchSaveResult commitSave() { + return services.commitSave(); + } + + void loadPickerScenario( + java.util.List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + services.loadPickerScenario(lines, originFile, scenarioName, startLine, endLine); + } + + void addLeaseListener(java.util.function.Consumer listener) { + services.addLeaseListener(listener); + } + + void addPlayerListener(Runnable listener) { + services.addPlayerListener(listener); + } + Optional workerLogFiles() { return services.workerLogFiles(); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java index 342d4efa..325c647a 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java @@ -19,11 +19,16 @@ private WorkbenchWebJson() { } public static String editorState(LiveScenarioPlayer player, Long executingId) { + return editorState(player, executingId, false); + } + + public static String editorState(LiveScenarioPlayer player, Long executingId, boolean locked) { Map payload = new LinkedHashMap<>(); payload.put("roots", blocks(GherkinBlockDocument.fromPlayer(player).roots())); payload.put("selectedId", player.selectedId().isPresent() ? player.selectedId().getAsLong() : null); payload.put("playheadId", player.playheadId().isPresent() ? player.playheadId().getAsLong() : null); payload.put("executingId", executingId); + payload.put("locked", locked); return write(payload); } @@ -32,6 +37,16 @@ public static String mappingState( MapChoice selected, MappingTreeModel model, String status + ) { + return mappingState(entries, selected, model, status, false); + } + + public static String mappingState( + List entries, + MapChoice selected, + MappingTreeModel model, + String status, + boolean locked ) { Map payload = new LinkedHashMap<>(); List> maps = new ArrayList<>(); @@ -57,6 +72,7 @@ public static String mappingState( } } payload.put("properties", properties); + payload.put("locked", locked); return write(payload); } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css index 099ad28c..54f009fc 100644 --- a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css @@ -90,3 +90,7 @@ button { border-left: 2px solid #e5e7eb; } .ghost { opacity: 0.45; } +body.locked .gherkin { caret-color: transparent; } +body.locked .handle { cursor: default; } +body.locked button { opacity: 0.5; pointer-events: none; } +body.locked { cursor: default; } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js index c5aace2d..76fcbed5 100644 --- a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js @@ -1,6 +1,6 @@ (function () { var board = document.getElementById("board"); - var state = { roots: [], selectedId: null, playheadId: null, executingId: null, suppress: false }; + var state = { roots: [], selectedId: null, playheadId: null, executingId: null, suppress: false, locked: false }; var dragging = null; function classify(text) { @@ -31,7 +31,7 @@ card.dataset.id = String(block.id); if (block.id === state.playheadId) card.classList.add("playhead"); if (block.id === state.executingId) card.classList.add("executing"); - card.draggable = true; + card.draggable = !state.locked; var row = document.createElement("div"); row.className = "row"; @@ -42,6 +42,7 @@ input.className = "gherkin"; input.value = block.text; input.spellcheck = false; + input.readOnly = !!state.locked; input.addEventListener("input", function () { block.text = input.value; autosize(input); @@ -69,6 +70,10 @@ }); card.addEventListener("dragstart", function (event) { + if (state.locked) { + event.preventDefault(); + return; + } dragging = { id: block.id }; card.classList.add("ghost"); event.dataTransfer.setData("text/plain", String(block.id)); @@ -190,7 +195,7 @@ } function report() { - if (state.suppress || !window.gherkinHost || !window.gherkinHost.documentChanged) return; + if (state.locked || state.suppress || !window.gherkinHost || !window.gherkinHost.documentChanged) return; var lines = []; flatten(state.roots, 0, lines); window.gherkinHost.documentChanged(JSON.stringify({ @@ -201,7 +206,7 @@ } function reportSeek() { - if (state.suppress || !window.gherkinHost || !window.gherkinHost.seek) return; + if (state.locked || state.suppress || !window.gherkinHost || !window.gherkinHost.seek) return; window.gherkinHost.seek(state.selectedId == null ? -1 : state.selectedId); } @@ -212,11 +217,16 @@ state.selectedId = payload.selectedId; state.playheadId = payload.playheadId; state.executingId = payload.executingId; + state.locked = !!payload.locked; + document.body.classList.toggle("locked", state.locked); + var add = document.getElementById("add-block"); + if (add) add.disabled = state.locked; render(); state.suppress = false; }; document.getElementById("add-block").addEventListener("click", function () { + if (state.locked) return; if (window.gherkinHost && window.gherkinHost.requestAddStep) { window.gherkinHost.requestAddStep(); } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js index 3bee9d02..92ddfdf8 100644 --- a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js @@ -34,7 +34,7 @@ wrap.className = "row"; var key = document.createElement("input"); key.value = property.key; - key.disabled = !model.restorable; + key.disabled = !model.restorable || !!model.locked; var type = document.createElement("select"); ["string", "numeric", "boolean", "object-as-json", "object-as-xml"].forEach(function (name) { var option = document.createElement("option"); @@ -43,10 +43,10 @@ if (name === property.type) option.selected = true; type.appendChild(option); }); - type.disabled = !model.restorable; + type.disabled = !model.restorable || !!model.locked; var value = document.createElement("textarea"); value.value = property.text; - value.disabled = !model.restorable; + value.disabled = !model.restorable || !!model.locked; function commit() { if (!window.mappingHost || !window.mappingHost.propertyChanged) return; window.mappingHost.propertyChanged(JSON.stringify({ @@ -70,12 +70,13 @@ } maps.addEventListener("change", function () { + if (model.locked) return; if (window.mappingHost && window.mappingHost.selectMap) { window.mappingHost.selectMap(maps.value); } }); document.getElementById("add").addEventListener("click", function () { - if (!model.restorable) return; + if (!model.restorable || model.locked) return; var key = "newProperty"; var n = 1; while (model.properties.some(function (item) { return item.key === key; })) { @@ -97,6 +98,11 @@ window.setMappingState = function (json) { model = typeof json === "string" ? JSON.parse(json) : json; status.textContent = model.status || ""; + document.body.classList.toggle("locked", !!model.locked); + var add = document.getElementById("add"); + if (add) add.disabled = !!model.locked || !model.restorable; + var mapsEl = document.getElementById("maps"); + if (mapsEl) mapsEl.disabled = !!model.locked; render(); }; diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java new file mode 100644 index 00000000..0d60e6a8 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java @@ -0,0 +1,168 @@ +package tools.dscode.workbench; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; +import tools.dscode.workbench.player.WorkbenchSaveResult; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchControllerLeaseTest { + @TempDir + Path project; + + @Test + void agentMutatingCallsFailUntilTheyHoldTheLease() { + try (WorkbenchController controller = new WorkbenchController(project)) { + IllegalStateException denied = assertThrows( + IllegalStateException.class, + () -> WorkbenchCallContext.runAs( + WorkbenchLeaseHolder.AGENT, + () -> controller.replaceLiveDocument(List.of("Given stay")) + ) + ); + assertTrue(denied.getMessage().contains("workbench_request_control")); + + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + controller.setCurrentAction("Editing the live buffer."); + controller.replaceLiveDocument(List.of("Scenario: Live", " Given stay")); + }); + assertTrue(controller.playerState().documentText().contains("Given stay")); + assertEquals("Copilot", controller.controlLeaseSnapshot().agentDisplayName()); + } + } + + @Test + void takeControlUnlocksHumanAndCancelsAgentSaveWaitWithoutWriting() throws Exception { + Path feature = project.resolve("src/test/resources/features/keep.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Keep + Scenario: Original + Given original + """); + String original = Files.readString(feature); + + try (WorkbenchController controller = new WorkbenchController(project)) { + controller.attachUi(); + controller.loadPickerScenario( + List.of("Feature: Keep", "", "Scenario: Original", " Given original", " And extra"), + feature, + "Original", + 2, + 3 + ); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + Thread saver = new Thread(() -> { + result.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return controller.requestSave(); + })); + }, "controller-save-wait"); + saver.setDaemon(true); + saver.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + + controller.takeControl(); + saver.join(2_000); + assertFalse(result.get().written()); + assertEquals("CANCELLED", result.get().status()); + assertEquals(original, Files.readString(feature)); + assertTrue(controller.controlLeaseSnapshot().humanHolds()); + } + } + + @Test + void deniedSaveDoesNotWriteAndApprovedSaveCopiesTheLiveScenario() throws Exception { + Path feature = project.resolve("src/test/resources/features/login.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Sign in + Scenario: Valid password + Given a user + """); + + try (WorkbenchController controller = new WorkbenchController(project)) { + controller.attachUi(); + controller.loadPickerScenario( + List.of("Feature: Sign in", "", "Scenario: Valid password", " Given a user", " And extra"), + feature, + "Valid password", + 2, + 3 + ); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference denied = new AtomicReference<>(); + Thread saver = new Thread(() -> { + denied.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return controller.requestSave(); + })); + }, "controller-save-deny"); + saver.setDaemon(true); + saver.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + String requestId = controller.controlLeaseSnapshot().pendingPermission().id(); + controller.answerPermission(requestId, false); + saver.join(2_000); + assertEquals("DENIED", denied.get().status()); + assertFalse(Files.readString(feature).contains("And extra")); + + CountDownLatch waitingAllow = new CountDownLatch(1); + AtomicReference allowed = new AtomicReference<>(); + Thread allowSaver = new Thread(() -> { + allowed.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waitingAllow.countDown(); + return controller.requestSave(); + })); + }, "controller-save-allow"); + allowSaver.setDaemon(true); + allowSaver.start(); + assertTrue(waitingAllow.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + controller.answerPermission(controller.controlLeaseSnapshot().pendingPermission().id(), true); + allowSaver.join(2_000); + assertTrue(allowed.get().written()); + assertTrue(Files.readString(feature).contains("And extra")); + } + } + + @Test + void demoCommitSaveStaysUnsavable() { + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchSaveResult result = controller.commitSave(); + assertFalse(result.written()); + assertEquals("UNSAVABLE", result.status()); + } + } + + private static void waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("Timed out waiting for controller lease condition."); + } + Thread.sleep(10); + } + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java new file mode 100644 index 00000000..6a814a44 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java @@ -0,0 +1,138 @@ +package tools.dscode.workbench.lease; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchControlLeaseTest { + + @Test + void humanHoldsByDefaultAndAgentMutationsFailUntilRequestControl() { + WorkbenchControlLease lease = new WorkbenchControlLease(); + assertTrue(lease.snapshot().humanHolds()); + + IllegalStateException denied = assertThrows( + IllegalStateException.class, + () -> WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, lease::requireMutatingAccess) + ); + assertTrue(denied.getMessage().contains("workbench_request_control")); + + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> lease.requestControl("Copilot")); + assertTrue(lease.snapshot().agentHolds()); + assertEquals("Copilot", lease.snapshot().agentDisplayName()); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, lease::requireMutatingAccess); + + IllegalStateException humanLocked = assertThrows(IllegalStateException.class, lease::requireMutatingAccess); + assertTrue(humanLocked.getMessage().contains("Take control")); + } + + @Test + void takeControlReturnsHumanUnlocksAndFailsInFlightPermissionWaits() throws Exception { + WorkbenchControlLease lease = new WorkbenchControlLease(); + lease.attachUi(); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + lease.requestControl("Copilot"); + lease.setCurrentAction("Playing the live scenario."); + }); + assertTrue(lease.snapshot().bannerText().contains("Copilot")); + assertTrue(lease.snapshot().bannerText().contains("Playing the live scenario.")); + + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread waiter = new Thread(() -> { + try { + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + lease.awaitPermission(new WorkbenchPermissionRequest( + "perm-1", + WorkbenchPermissionKind.SAVE, + "Copy these live steps into file demo.feature / scenario Demo?", + Path.of("demo.feature").toString(), + "Demo" + )); + }); + } catch (Throwable thrown) { + failure.set(thrown); + } finally { + finished.countDown(); + } + }, "lease-permission-wait"); + waiter.setDaemon(true); + waiter.start(); + + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> lease.snapshot().pending().isPresent(), 2_000); + lease.takeControl(); + assertTrue(finished.await(2, TimeUnit.SECONDS)); + assertTrue(failure.get() instanceof WorkbenchPermissionCancelledException); + assertTrue(lease.snapshot().humanHolds()); + assertTrue(lease.snapshot().pending().isEmpty()); + } + + @Test + void allowAndDenyCompletePermissionWaits() throws Exception { + WorkbenchControlLease lease = new WorkbenchControlLease(); + lease.attachUi(); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> lease.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference decision = new AtomicReference<>(); + Thread waiter = new Thread(() -> { + decision.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return lease.awaitPermission(new WorkbenchPermissionRequest( + "perm-2", + WorkbenchPermissionKind.SAVE, + "Copy?", + Path.of("a.feature").toString(), + "A" + )); + })); + }, "lease-allow"); + waiter.setDaemon(true); + waiter.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> lease.snapshot().pending().isPresent(), 2_000); + lease.answerPermission("perm-2", false); + waiter.join(2_000); + assertEquals(WorkbenchPermissionDecision.DENY, decision.get()); + } + + @Test + void headlessPermissionIsGrantedByTheExplicitToolCall() { + WorkbenchControlLease lease = new WorkbenchControlLease(); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> lease.requestControl("stdio")); + WorkbenchPermissionDecision decision = WorkbenchCallContext.callAs( + WorkbenchLeaseHolder.AGENT, + () -> lease.awaitPermission(new WorkbenchPermissionRequest( + "perm-3", + WorkbenchPermissionKind.SAVE, + "Copy?", + Path.of("a.feature").toString(), + "A" + )) + ); + assertEquals(WorkbenchPermissionDecision.ALLOW, decision); + assertFalse(lease.snapshot().uiAttached()); + } + + private static void waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("Timed out waiting for lease condition."); + } + Thread.sleep(10); + } + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java new file mode 100644 index 00000000..b31fec6e --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java @@ -0,0 +1,98 @@ +package tools.dscode.workbench.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.workbench.WorkbenchController; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchAttachServerTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @TempDir + Path project; + + @Test + void writesLocalhostAttachStateAndRequiresTheSessionToken() throws Exception { + try (WorkbenchController controller = new WorkbenchController(project); + WorkbenchAttachServer server = WorkbenchAttachServer.start(controller, project)) { + Path stateFile = WorkbenchAttachServer.attachStateFile(project); + assertTrue(Files.isRegularFile(stateFile)); + JsonNode state = JSON.readTree(Files.readString(stateFile)); + assertEquals(server.url(), state.get("url").asText()); + assertEquals(server.token(), state.get("token").asText()); + assertTrue(server.url().startsWith("http://127.0.0.1:")); + assertEquals("ui-attach", state.get("mode").asText()); + + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + HttpResponse denied = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/lease")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(401, denied.statusCode()); + + HttpResponse lease = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/lease")) + .header("Authorization", "Bearer " + server.token()) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(200, lease.statusCode()); + assertTrue(lease.body().contains("\"holder\":\"HUMAN\"")); + + HttpResponse requested = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/tools/workbench_request_control")) + .header("Authorization", "Bearer " + server.token()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"agentName\":\"Copilot\"}")) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(200, requested.statusCode()); + assertTrue(requested.body().contains("\"holder\":\"AGENT\"")); + assertTrue(requested.body().contains("Copilot")); + } + assertTrue(Files.notExists(WorkbenchAttachServer.attachStateFile(project))); + } + + @Test + void mutatingToolWithoutLeaseFailsClearly() throws Exception { + try (WorkbenchController controller = new WorkbenchController(project); + WorkbenchAttachServer server = WorkbenchAttachServer.start(controller, project)) { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/tools/workbench_player_replace_document")) + .header("X-Workbench-Token", server.token()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"text\":\"Given stay\"}")) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(400, response.statusCode()); + assertTrue(response.body().contains("workbench_request_control")); + } + } + + @Test + void headlessStdioLeaseDoesNotRequireABanner() { + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("stdio")); + assertTrue(controller.controlLeaseSnapshot().agentHolds()); + org.junit.jupiter.api.Assertions.assertFalse(controller.controlLeaseSnapshot().uiAttached()); + } + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java index bc8ec522..a1096472 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java @@ -64,6 +64,9 @@ void packagedServerInitializesListsToolsInvokesControllerAndKeepsStdoutProtocolO assertTrue(toolNames.contains("workbench_breakpoint_add")); assertTrue(toolNames.contains("workbench_step_override_compile")); assertTrue(toolNames.contains("workbench_step_override_clear")); + assertTrue(toolNames.contains("workbench_request_control")); + assertTrue(toolNames.contains("workbench_player_state")); + assertTrue(toolNames.contains("workbench_request_save")); JsonNode status = harness.toolCall(3, "workbench_worker_status", "{}"); assertFalse(status.at("/result/isError").asBoolean()); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java new file mode 100644 index 00000000..7545fc63 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java @@ -0,0 +1,98 @@ +package tools.dscode.workbench.player; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LiveFeatureSaveTest { + @TempDir + Path project; + + @Test + void demoBufferIsUnsavableAndLoadDoesNotWriteFiles() throws Exception { + Path feature = project.resolve("login.feature"); + Files.writeString(feature, """ + Feature: Sign in + Scenario: Valid password + Given a user + Scenario: Locked account + Given a lock + """); + String original = Files.readString(feature); + LivePlaybackCoordinator playback = new LivePlaybackCoordinator(LiveScenarioPlayer.interactiveBuffer()); + WorkbenchSavePreview demo = LiveFeatureSave.preview(playback); + assertFalse(demo.savable()); + assertTrue(demo.summary().contains("session-only")); + + playback.loadScenario( + List.of("Feature: Sign in", "", "Scenario: Locked account", " Given a lock"), + feature, + "Locked account", + 4, + 5 + ); + assertEquals(original, Files.readString(feature)); + assertTrue(LiveFeatureSave.preview(playback).savable()); + } + + @Test + void writeSplicesOnlyTheOriginatingScenario() throws Exception { + Path feature = project.resolve("login.feature"); + Files.writeString(feature, """ + Feature: Sign in + Scenario: Valid password + Given a user + Scenario: Locked account + Given a lock + """); + LivePlaybackCoordinator playback = new LivePlaybackCoordinator(new LiveScenarioPlayer(List.of( + "Feature: Sign in", + "", + "Scenario: Locked account", + " Given a lock", + " And stay locked" + ))); + playback.loadScenario( + List.of( + "Feature: Sign in", + "", + "Scenario: Locked account", + " Given a lock", + " And stay locked" + ), + feature, + "Locked account", + 4, + 5 + ); + + WorkbenchSaveResult result = LiveFeatureSave.write(playback); + assertTrue(result.written()); + String saved = Files.readString(feature); + assertTrue(saved.contains("Scenario: Valid password")); + assertTrue(saved.contains("Given a user")); + assertTrue(saved.contains("And stay locked")); + assertTrue(saved.contains("Scenario: Locked account")); + } + + @Test + void denyPathNeverCallsWrite() throws Exception { + Path feature = project.resolve("only.feature"); + Files.writeString(feature, """ + Feature: Only + Scenario: Keep me + Given original + """); + String original = Files.readString(feature); + WorkbenchSaveResult denied = WorkbenchSaveResult.denied(); + assertFalse(denied.written()); + assertEquals(original, Files.readString(feature)); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java index 7eb718af..7904acb9 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java @@ -62,5 +62,6 @@ void loadingAScenarioReplacesTheLiveBufferWithoutWritingFiles() { coordinator.loadDefaultDemo(); assertTrue(coordinator.player().documentText().contains("navigate to: URL.home")); + assertTrue(coordinator.origin().savable() == false); } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java index ce251b42..f8b6a237 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java @@ -18,6 +18,13 @@ import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; +import tools.dscode.workbench.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -342,6 +349,9 @@ private static final class RecordingServices { private final List calls = new ArrayList<>(); private final List overrides = new ArrayList<>(); private final List breakpoints = new ArrayList<>(); + private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + private final LivePlaybackCoordinator playback = new LivePlaybackCoordinator(player); + private final WorkbenchControlLease lease = new WorkbenchControlLease(); private RuntimeException synchronizationFailure; private WorkbenchWorkerStatus status = new WorkbenchWorkerStatus( false, null, null, null, null, false, null @@ -527,6 +537,49 @@ yield new ControlBridgeServiceCallResult( yield count; } case "projectRoot" -> Path.of("consumer"); + case "player" -> player; + case "playback" -> playback; + case "playerState" -> new WorkbenchPlayerState( + player.documentText(), + player.lines().stream().map(LiveScenarioPlayer.Line::text).toList(), + player.state(), + player.playheadId().isPresent() ? player.playheadId().getAsLong() : null, + player.playheadLine().map(LiveScenarioPlayer.Line::text).orElse(""), + player.selectedId().isPresent() ? player.selectedId().getAsLong() : null, + playback.origin().file() == null ? "" : playback.origin().file().toString(), + playback.origin().scenarioName(), + playback.origin().savable() + ); + case "controlLease" -> lease; + case "controlLeaseSnapshot" -> lease.snapshot(); + case "requestControl" -> lease.requestControl((String) args[0]); + case "releaseControl" -> lease.releaseControl(); + case "takeControl" -> lease.takeControl(); + case "setCurrentAction" -> lease.setCurrentAction((String) args[0]); + case "answerPermission" -> { + lease.answerPermission((String) args[0], (Boolean) args[1]); + yield null; + } + case "attachUi" -> { + lease.attachUi(); + yield null; + } + case "detachUi" -> { + lease.detachUi(); + yield null; + } + case "addLeaseListener" -> { + lease.addListener((java.util.function.Consumer) args[0]); + yield null; + } + case "removeLeaseListener" -> { + lease.removeListener((java.util.function.Consumer) args[0]); + yield null; + } + case "addPlayerListener", "removePlayerListener", "loadPickerScenario", + "loadDefaultDemo", "replaceLiveDocument" -> null; + case "savePreview" -> WorkbenchSavePreview.unsavable("session-only"); + case "requestSave", "commitSave" -> WorkbenchSaveResult.unsavable("session-only"); case "workerLogFiles" -> { calls.add("workerLogFiles"); yield java.util.Optional.empty(); diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index 0adb407d..eb794478 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 1a082cd2..86a1c5f4 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -478,13 +478,27 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` @@ -521,9 +535,12 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` @@ -531,6 +548,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchLiveSessionTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md index 1b9c6763..d4a79d35 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md @@ -35,7 +35,7 @@ The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Block Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. -The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario. The default demo is a small browser scenario against the Maven consumer local test site: +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario and confirm the copy. The default demo is a small browser scenario against the Maven consumer local test site: ```gherkin Feature: Workbench Live Scenario @@ -107,6 +107,17 @@ The Terminal tails the worker stdout/stderr files Workbench already creates unde The explorer is a rewind/play/focus timeline of retained Pickleball diagnostic runs. Screenshot frames are shown with the Gherkin step that was running when they were taken. Denser layers follow the repository evidence order and only open when the retained files exist. If `reports/diagnostic-runs/run-catalog.json` is missing, the panel stays empty and says so. +## Watched-agent control lease + +The live player is a collaborative testing space, not a second editor. Swing and an attached agent share one `LiveScenarioPlayer` in the Workbench controller. + +- A human can work alone: edit/play the live buffer, then **Save** asks before copying into the original scenario in the original `.feature` file. +- An agent attaches to the running UI through `.pickleball/workbench/attach.json` (localhost JSON tools over the same `WorkbenchServices`). It must not start a second Workbench JVM or worker. +- After `workbench_request_control`, Swing play/edit/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. +- `workbench_request_save` is the only original-feature write path for the agent. With the UI attached it blocks on Allow/Deny. Deny writes nothing. Headless stdio MCP may hold the lease without a banner; Save is still an explicit tool. + +See [pickleball-workbench.md](pickleball-workbench.md) for attach discovery, tool names, and stdout rules. + ## Focused validation The included consumer `@control-bridge` scenario verifies: @@ -115,7 +126,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, and the non-empty browser demo seed. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index 5b8fa58d..e63e0903 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -22,7 +22,7 @@ Workbench owns synchronization, `.pickleball/workbench/` disposable state, worke `pickleball-control-protocol` owns only immutable wire records, request/response envelopes, transport constants, capabilities, and version/minimum-version negotiation. Worker-side bridge server/coordinator/bootstrap and all translation to runtime operations remain in Pickleball core. -MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. +MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. A visible UI keeps one Workbench JVM and one consumer worker. An AI agent attaches to that live session through a localhost HTTP JSON facade; it must not start a second Workbench or a second worker. The canonical worker bridge environment is: @@ -149,7 +149,7 @@ The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level - playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live; **Save** writes back to the originating `.feature` file when one was loaded. The Workbench-owned demo has no save path. +The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live. **Save** is confirmation-gated: it copies the live scenario into the originating `.feature` file and scenario only after Allow. The Workbench-owned demo has no save path. Workbench never writes `.feature` files on picker load or on Deny. The playhead behaves like an audio-player needle: @@ -242,6 +242,63 @@ Synchronization, worker actions, live bridge calls, Mapping operations, event re The UI is intentionally not a project IDE, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. The Live Scenario Editor is a session-scoped Gherkin player/editor, not a workspace file explorer and not an automatic writer of consumer `.feature` files. +### Watched AI-agent control lease + +Workbench owns one control lease for the live session. Swing and MCP/HTTP adapters share it; the lease is not Swing-only state. + +- Holder is `HUMAN` when the UI is up, or `AGENT` after an attached agent requests control. +- The snapshot also carries the agent display name, `currentAction` banner text, and at most one pending permission request. + +While the human holds the lease, Swing play/edit/mapping/save/worker controls stay enabled. Agent mutating calls fail clearly until `workbench_request_control`. + +While an agent holds the lease, the human can watch the same window. Play, edit, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. + +The agent should update `currentAction` as it works. Playhead, Mapping, Terminal, and screenshots follow because the agent uses the same `LiveScenarioPlayer` / worker as the UI. Testing the live scenario (`executeStep`, play, Mapping reads, evidence) is allowed on the agent lease. Copying the live scenario into the original `.feature` is not; that goes through `workbench_request_save` and waits for Allow/Deny in the Swing banner. + +Human **Save** uses the same service. After a picker scenario was loaded, Swing asks: copy these live steps into file X / scenario Y? Deny writes nothing. The demo buffer stays unsavable. + +### Attaching an agent to a visible UI + +UI mode cannot share process stdout with stdio MCP. Starting `mcp` while the UI is already running would be a second Workbench JVM. Instead, `ui` starts a 127.0.0.1-only JSON attach endpoint over the same `WorkbenchServices` / `WorkbenchMcpTools` methods and writes disposable discovery state: + +```text +.pickleball/workbench/attach.json +``` + +Example: + +```json +{ + "url": "http://127.0.0.1:51234", + "token": "hex-session-token", + "pid": 12345, + "project": "/path/to/maven-consumer-project", + "mode": "ui-attach", + "bind": "127.0.0.1" +} +``` + +A Copilot or other MCP-style client finds that file in the consumer project, then: + +1. `GET {url}/health` — liveness, no token. +2. `GET {url}/lease` and `GET {url}/player` — `Authorization: Bearer ` or `X-Workbench-Token`. +3. `POST {url}/tools/workbench_request_control` with `{"agentName":"Copilot"}`. +4. Use the existing live tools (`workbench_execute_step`, Mapping, evidence, worker) while holding the lease, and `workbench_set_current_action` so the human can watch. +5. `POST {url}/tools/workbench_request_save` to ask to copy the live scenario into the original feature. The call blocks until the human clicks Allow or Deny, or Take control. + +Headless `java -jar pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. + +From `maven-consumer-project`: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +Then point the agent at `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session. + ## MCP stdio Start the lightweight non-Spring MCP server for a synchronized consumer project: @@ -283,9 +340,16 @@ workbench_worker_stop workbench_worker_status ``` -Live runtime and Mapping: +Live runtime, Mapping, and watched-agent control: ```text +workbench_request_control +workbench_release_control +workbench_set_current_action +workbench_control_lease +workbench_player_state +workbench_player_replace_document +workbench_request_save workbench_execute_step workbench_mapping_get workbench_mapping_put @@ -324,6 +388,8 @@ workbench_step_override_clear `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. +Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. + Controller/runtime failures are returned as MCP tool results with `isError=true`. They are not printed as arbitrary protocol output. ## Scope boundary @@ -381,6 +447,10 @@ Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-c 12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. 13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. 14. Verify blocking runtime actions leave the Swing UI responsive. +15. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. +16. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/mapping/save/worker controls. **Take control** remains enabled. +17. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. +18. The default demo remains unsavable for both human Save and agent `workbench_request_save`. ## Regression From f00a04116ae63d12ca6408008c52ae3259b4fe43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 18:41:20 +0000 Subject: [PATCH 09/15] Stop Workbench Play from stalling after the first live step. executeStep already advances the RUNNING playhead; the Swing Play loop was marking the same captured step again, which threw after playbackBusy was cleared and skipped the next schedulePlaybackStep. Keep executeStep as the single playhead owner, make leftover marks no-ops, and continue automatic playback while RUNNING. Co-authored-by: ds-code-t --- docs/agent/feature-map.md | 2 +- docs/pickleball-workbench-player.md | 3 +- docs/pickleball-workbench.md | 2 +- .../dscode/workbench/WorkbenchController.java | 16 +--- .../player/LivePlaybackCoordinator.java | 20 +++++ .../workbench/player/LiveScenarioPlayer.java | 26 +++++-- .../dscode/workbench/ui/WorkbenchFrame.java | 8 +- .../player/LivePlaybackCoordinatorTest.java | 76 +++++++++++++++++++ .../player/LiveScenarioPlayerTest.java | 29 +++++++ .../guidance/docs/agent/feature-map.md | 2 +- .../docs/pickleball-workbench-player.md | 3 +- .../guidance/docs/pickleball-workbench.md | 2 +- 12 files changed, 161 insertions(+), 28 deletions(-) diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index eb794478..d8772d43 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md index d4a79d35..88afe3d8 100644 --- a/docs/pickleball-workbench-player.md +++ b/docs/pickleball-workbench-player.md @@ -59,6 +59,7 @@ Scenario: Open the local test site - **Step** executes only the Step Editor text against the current paused live context and leaves automatic scenario playback paused. - **From Here** creates a fresh interactive scenario context and treats the selected/playhead executable step as the first step of that run, then continues through the remaining buffer. - Fresh scenario playback restarts the consumer worker so browser, Mapping, service, and other side effects from a previous run do not leak into a new **Play** or **From Here** run. +- Automatic **Play** / **From Here** send each executable live-buffer line through `executeStep`. While the player is `RUNNING`, that controller call is the single playhead owner: success advances to the next executable line, failure pauses on the failed line. The Swing Play loop then refreshes and schedules the next line without remaking the same mark. An attached agent `execute_step` uses the same follow so the spectator playhead stays aligned. Isolated **Step** pauses first, so it does not move the playhead. Marking an already-consumed step is a no-op, so a leftover UI callback cannot abort playback after a successful worker step. - Reaching the end while playing changes the player to **Waiting for step** rather than stopping. Typing a new step and pressing **Enter** appends it to the end of the live scenario and executes it as part of the same live run. - **Ctrl+Enter** updates the selected line in place. The whole-scenario editor also accepts ordinary typing at any line. - The editor highlights the current playhead line. Successful lines do not retain checkmarks or become locked. @@ -126,7 +127,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, leftover Play-loop playhead marks after `executeStep`, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index e63e0903..ae463bec 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -154,7 +154,7 @@ The Live Scenario Editor is a session-scoped Gherkin document presented as snap- The playhead behaves like an audio-player needle: - clicking a scenario line instantly seeks the playhead to that line; -- while a run is active, the playhead follows the current/next executable step after each success; +- while a run is active, `executeStep` advances the playhead once on success (or pauses it on failure); the UI Play loop continues from that new next step without remaking the same mark; - **Pause** and **Stop** do not claim to rewind browser, Mapping, service, or other worker side effects. Global **Play** always starts a fresh interactive scenario context and runs from the first executable step, even if the playhead is elsewhere. Fresh **Play** / **From Here** runs restart the consumer worker so prior side effects do not masquerade as the start of a scenario. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index 717e4d9d..b590ae02 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -365,18 +365,10 @@ public void close() { } private void maybeAdvancePlayhead(String text, boolean successful) { - LiveScenarioPlayer.Line next = player.nextStep().orElse(null); - if (next != null && text != null && next.text().equals(text) - && player.state() == LiveScenarioPlayer.State.RUNNING) { - try { - if (successful) { - player.markCurrentStepExecuted(next.id()); - } else { - player.markCurrentStepFailed(next.id()); - } - } catch (RuntimeException ignored) { - // Playhead follow is best-effort presentation; worker execution already finished. - } + try { + playback.followExecutedStep(text, successful); + } catch (RuntimeException ignored) { + // Playhead follow is best-effort presentation; worker execution already finished. } notifyPlayer(); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java index d6912749..07b9eba0 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java @@ -106,4 +106,24 @@ public boolean waitingForStep() { public boolean running() { return player.state() == LiveScenarioPlayer.State.RUNNING; } + + /** + * Single owner for playhead follow after a worker {@code executeStep}. + * Advances only while {@link LiveScenarioPlayer.State#RUNNING} and only when + * the executed text is the current next step. Isolated Step Only pauses first, + * so it does not move the playhead. Attached-agent {@code execute_step} uses + * this same follow while a UI Play run is in progress. + */ + public void followExecutedStep(String text, boolean successful) { + LiveScenarioPlayer.Line next = player.nextStep().orElse(null); + if (next == null || text == null || !next.text().equals(text) + || player.state() != LiveScenarioPlayer.State.RUNNING) { + return; + } + if (successful) { + player.markCurrentStepExecuted(next.id()); + } else { + player.markCurrentStepFailed(next.id()); + } + } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java index badbb368..d3af2079 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java @@ -283,9 +283,16 @@ public void pauseForIsolatedExecution() { state = State.PAUSED; } - /** Advances a successful run to the next executable line and stays in play at end. */ + /** + * Advances a successful run to the next executable line and stays in play at end. + * Already-consumed or stale ids are ignored so a leftover Play-loop callback + * cannot abort automatic playback. + */ public void markCurrentStepExecuted(long stepId) { - int index = requireCurrentStep(stepId); + int index = currentExecutableIndex(stepId); + if (index < 0) { + return; + } lastExecutedId = stepId; executionIndex = findNextExecutableIndex(index + 1); if (executionIndex < lines.size()) { @@ -295,9 +302,16 @@ public void markCurrentStepExecuted(long stepId) { } } - /** Leaves a failed run paused on its failed line. */ + /** + * Leaves a failed run paused on its failed line. Already-consumed or stale + * ids are ignored. + */ public void markCurrentStepFailed(long stepId) { - executionIndex = requireCurrentStep(stepId); + int index = currentExecutableIndex(stepId); + if (index < 0) { + return; + } + executionIndex = index; playheadId = stepId; selectedId = stepId; state = State.PAUSED; @@ -357,10 +371,10 @@ private int findNextExecutableIndex(int from) { return lines.size(); } - private int requireCurrentStep(long id) { + private int currentExecutableIndex(long id) { if (executionIndex >= lines.size() || !lines.get(executionIndex).executable() || lines.get(executionIndex).id() != id) { - throw new IllegalStateException("Step " + id + " is not the current execution step."); + return -1; } return executionIndex; } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index 0b891742..038e4ff8 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -948,10 +948,10 @@ private void schedulePlaybackStep() { executingStepId = null; appendTerminal(step.text(), result.output(), result.events()); - if (result.successful()) { - player.markCurrentStepExecuted(step.id()); - } else { - player.markCurrentStepFailed(step.id()); + // executeStep already advanced or paused the playhead while RUNNING. + // Do not remake that mark here; a leftover mark of the captured id + // used to abort automatic playback after the first successful step. + if (!result.successful()) { player.clickLine(step.id()); showLine(step.id()); } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java index 7904acb9..003c6bbd 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java @@ -4,7 +4,9 @@ import java.util.List; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class LivePlaybackCoordinatorTest { @@ -64,4 +66,78 @@ void loadingAScenarioReplacesTheLiveBufferWithoutWritingFiles() { assertTrue(coordinator.player().documentText().contains("navigate to: URL.home")); assertTrue(coordinator.origin().savable() == false); } + + @Test + void executeStepOwnsThePlayheadSoALeftoverUiMarkCannotStallPlay() { + LivePlaybackCoordinator coordinator = new LivePlaybackCoordinator(LiveScenarioPlayer.interactiveBuffer()); + LiveScenarioPlayer player = coordinator.player(); + coordinator.playFromStart(); + + LiveScenarioPlayer.Line first = player.nextStep().orElseThrow(); + assertTrue(first.text().contains("navigate to: URL.home")); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + + coordinator.followExecutedStep(first.text(), true); + assertDoesNotThrow(() -> simulateFrameSuccessCallback(player, first)); + + LiveScenarioPlayer.Line second = player.nextStep().orElseThrow(); + assertNotEquals(first.id(), second.id()); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + + int walked = 1; + while (player.state() == LiveScenarioPlayer.State.RUNNING && player.nextStep().isPresent()) { + LiveScenarioPlayer.Line step = player.nextStep().orElseThrow(); + coordinator.followExecutedStep(step.text(), true); + assertDoesNotThrow(() -> simulateFrameSuccessCallback(player, step)); + walked++; + } + + assertTrue(walked >= 3, "demo buffer should walk more than the first live step, walked=" + walked); + assertEquals(LiveScenarioPlayer.State.WAITING_FOR_STEP, player.state()); + assertTrue(player.nextStep().isEmpty()); + } + + @Test + void agentExecuteStepAdvancesThePlayheadOnceWhileRunning() { + LivePlaybackCoordinator coordinator = new LivePlaybackCoordinator(new LiveScenarioPlayer(List.of( + "Given first", + "And second", + "Then third" + ))); + LiveScenarioPlayer player = coordinator.player(); + coordinator.playFromStart(); + LiveScenarioPlayer.Line first = player.nextStep().orElseThrow(); + + coordinator.followExecutedStep(first.text(), true); + assertEquals("And second", player.nextStep().orElseThrow().text()); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + + coordinator.followExecutedStep(first.text(), true); + assertEquals("And second", player.nextStep().orElseThrow().text()); + } + + @Test + void isolatedStepOnlyDoesNotAdvanceThePlayhead() { + LivePlaybackCoordinator coordinator = new LivePlaybackCoordinator(new LiveScenarioPlayer(List.of( + "Given first", + "And second" + ))); + LiveScenarioPlayer player = coordinator.player(); + coordinator.playFromStart(); + LiveScenarioPlayer.Line first = player.nextStep().orElseThrow(); + + coordinator.stepOnly(); + coordinator.followExecutedStep(first.text(), true); + + assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + assertEquals(first.id(), player.nextStep().orElseThrow().id()); + } + + /** + * Former WorkbenchFrame success callback: remake the captured step mark after + * executeStep already followed the playhead, then continue while RUNNING. + */ + private static void simulateFrameSuccessCallback(LiveScenarioPlayer player, LiveScenarioPlayer.Line captured) { + player.markCurrentStepExecuted(captured.id()); + } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java index 03f2f162..ea7142fe 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -226,4 +227,32 @@ void insertingIntoCompletedMiddleDoesNotReplayLaterSteps() { assertTrue(player.nextStep().isEmpty()); assertNotEquals("And inserted in middle", player.lines().getFirst().text()); } + + @Test + void leftoverPlayheadMarksAreIdempotentAndDoNotThrow() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given first", + "And second", + "Then third" + )); + player.startFromBeginning(); + long first = player.nextStep().orElseThrow().id(); + long second = player.lines().get(1).id(); + + assertDoesNotThrow(() -> player.markCurrentStepExecuted(first)); + assertDoesNotThrow(() -> player.markCurrentStepExecuted(first)); + assertDoesNotThrow(() -> player.markCurrentStepExecuted(second + 99)); + + assertEquals(second, player.nextStep().orElseThrow().id()); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + + assertDoesNotThrow(() -> player.markCurrentStepFailed(first)); + assertEquals(LiveScenarioPlayer.State.RUNNING, player.state()); + assertEquals(second, player.nextStep().orElseThrow().id()); + + assertDoesNotThrow(() -> player.markCurrentStepFailed(second)); + assertDoesNotThrow(() -> player.markCurrentStepFailed(second)); + assertEquals(LiveScenarioPlayer.State.PAUSED, player.state()); + assertEquals(second, player.nextStep().orElseThrow().id()); + } } diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index eb794478..d8772d43 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md index d4a79d35..88afe3d8 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md @@ -59,6 +59,7 @@ Scenario: Open the local test site - **Step** executes only the Step Editor text against the current paused live context and leaves automatic scenario playback paused. - **From Here** creates a fresh interactive scenario context and treats the selected/playhead executable step as the first step of that run, then continues through the remaining buffer. - Fresh scenario playback restarts the consumer worker so browser, Mapping, service, and other side effects from a previous run do not leak into a new **Play** or **From Here** run. +- Automatic **Play** / **From Here** send each executable live-buffer line through `executeStep`. While the player is `RUNNING`, that controller call is the single playhead owner: success advances to the next executable line, failure pauses on the failed line. The Swing Play loop then refreshes and schedules the next line without remaking the same mark. An attached agent `execute_step` uses the same follow so the spectator playhead stays aligned. Isolated **Step** pauses first, so it does not move the playhead. Marking an already-consumed step is a no-op, so a leftover UI callback cannot abort playback after a successful worker step. - Reaching the end while playing changes the player to **Waiting for step** rather than stopping. Typing a new step and pressing **Enter** appends it to the end of the live scenario and executes it as part of the same live run. - **Ctrl+Enter** updates the selected line in place. The whole-scenario editor also accepts ordinary typing at any line. - The editor highlights the current playhead line. Successful lines do not retain checkmarks or become locked. @@ -126,7 +127,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, leftover Play-loop playhead marks after `executeStep`, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index e63e0903..ae463bec 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -154,7 +154,7 @@ The Live Scenario Editor is a session-scoped Gherkin document presented as snap- The playhead behaves like an audio-player needle: - clicking a scenario line instantly seeks the playhead to that line; -- while a run is active, the playhead follows the current/next executable step after each success; +- while a run is active, `executeStep` advances the playhead once on success (or pauses it on failure); the UI Play loop continues from that new next step without remaking the same mark; - **Pause** and **Stop** do not claim to rewind browser, Mapping, service, or other worker side effects. Global **Play** always starts a fresh interactive scenario context and runs from the first executable step, even if the playhead is elsewhere. Fresh **Play** / **From Here** runs restart the consumer worker so prior side effects do not masquerade as the start of a scenario. From f2fa2dd12437a67e2e1f5276f1237050ac92b358 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 19:13:59 +0000 Subject: [PATCH 10/15] Add Workbench scenario name/tag filters and a Text/Blocks editor toggle. Name and tag filtering is the primary left-rail UI; feature-file selection is a collapsed optional filter. The live editor switches between Gherkin text and the existing block view without changing the LiveScenarioPlayer buffer, playhead, or Play/Step/From Here contract. Co-authored-by: ds-code-t --- docs/agent/feature-map.md | 4 +- docs/agent/repository-index.md | 4 + docs/pickleball-workbench-player.md | 14 +- docs/pickleball-workbench.md | 71 +++--- pickleball-workbench/AGENTS.md | 8 +- .../catalog/ConsumerFeatureCatalog.java | 116 +++++++-- .../workbench/catalog/ScenarioFilter.java | 232 ++++++++++++++++++ .../workbench/player/LiveEditorView.java | 66 +++++ .../workbench/ui/FeaturePickerPanel.java | 213 +++++++++++----- .../dscode/workbench/ui/WorkbenchFrame.java | 81 +++++- .../catalog/ConsumerFeatureCatalogTest.java | 136 +++++++++- .../workbench/catalog/ScenarioFilterTest.java | 125 ++++++++++ .../workbench/player/LiveEditorViewTest.java | 50 ++++ .../guidance/docs/agent/feature-map.md | 4 +- .../guidance/docs/agent/repository-index.md | 4 + .../docs/pickleball-workbench-player.md | 14 +- .../guidance/docs/pickleball-workbench.md | 71 +++--- 17 files changed, 1031 insertions(+), 182 deletions(-) create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index d8772d43..3a2226af 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is the Text vs Blocks presentation choice for that same buffer; toggling it must not change document text, selection, or playhead id. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is Gherkin text or a block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 86a1c5f4..6d0e3fea 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -477,6 +477,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/build.gradle` - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java` @@ -492,6 +493,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` @@ -534,12 +536,14 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js` - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md index 88afe3d8..96578c8b 100644 --- a/docs/pickleball-workbench-player.md +++ b/docs/pickleball-workbench-player.md @@ -25,15 +25,17 @@ The player, picker, Mapping editor, Terminal, and Diagnostic explorer do not cha ## Feature and scenario picker -The expandable left rail lists `.feature` files from the synchronized consumer project: manifest source roots, conventional `src/test/resources/features`, live merged `features/` resources, and an explicit project-owned `pkb_features` value when present. It does not crawl an unrelated git worktree. +The left rail lists scenarios from `.feature` files in the synchronized consumer project: manifest source roots, conventional `src/test/resources/features`, live merged `features/` resources, and an explicit project-owned `pkb_features` value when present. It does not crawl an unrelated git worktree. -Toggle browse mode between Gherkin Feature name and file name + directory path. Click selects or deselects features. The scenario list then shows scenarios under the selection. With no feature selected, the search bar filters every scenario in that project catalog. Clicking a scenario loads it into the live editor. The default demo remains loaded until a scenario is chosen. **Save** is the only write-back path. +Name and tag filtering is the primary UI. Type a scenario name and choose a match mode: starts with, contains (default), ends with, or full match. All four modes are case-insensitive and match only the Gherkin `Scenario` / `Scenario Outline` title. Include tags must all be present (AND). Exclude tags drop a scenario if it has any of them (NOT). Tag fields accept values with or without a leading `@` and split on commas and/or whitespace. Empty include/exclude means no tag constraint. Feature, Rule, scenario/outline, and Examples tags are inherited as Cucumber does; Workbench parses them from the same catalog files and does not call Cucumber from the controller JVM. + +Feature-file selection is secondary and collapsed behind **Filter by feature**. With no feature selected, name/tag filters apply to every catalog scenario. Opening that panel still toggles browse mode between Gherkin Feature name and file name + directory path, and click still selects or deselects features. Clicking a scenario loads it into the live editor. The default demo remains loaded until a scenario is chosen. **Save** is the only write-back path. ## Live Scenario Editor -The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Blocks are Gherkin text, including `Given` / `When` / `Then`. Nested steps and `IF` / `ELSE` snap as parent/child using leading colons. Clicking a block instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. +The center editor is an embedded HTML/JS block editor in JavaFX `WebView`, or ordinary Gherkin text on the same `LiveScenarioPlayer` buffer. A prominent **Text | Blocks** toggle next to the editor heading switches those views without losing playhead, selection, or document text. Play, Step, and From Here keep using the same `LiveScenarioPlayer`. Blocks are Gherkin text, including `Given` / `When` / `Then`. Nested steps and `IF` / `ELSE` snap as parent/child using leading colons. Clicking a block or line instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. -Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. +Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text and Blocks is shown as unavailable. The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario and confirm the copy. The default demo is a small browser scenario against the Maven consumer local test site: @@ -114,7 +116,7 @@ The live player is a collaborative testing space, not a second editor. Swing and - A human can work alone: edit/play the live buffer, then **Save** asks before copying into the original scenario in the original `.feature` file. - An agent attaches to the running UI through `.pickleball/workbench/attach.json` (localhost JSON tools over the same `WorkbenchServices`). It must not start a second Workbench JVM or worker. -- After `workbench_request_control`, Swing play/edit/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. +- After `workbench_request_control`, Swing play/edit/picker/filter/editor-view/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. - `workbench_request_save` is the only original-feature write path for the agent. With the UI attached it blocks on Allow/Deny. Deny writes nothing. Headless stdio MCP may hold the lease without a banner; Save is still an explicit tool. See [pickleball-workbench.md](pickleball-workbench.md) for attach discovery, tool names, and stdout rules. @@ -127,7 +129,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, leftover Play-loop playhead marks after `executeStep`, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. +Workbench player/editor unit tests cover picker name/tag/feature filtering (including Feature-level tag inheritance), block buffer ↔ player model, Text | Blocks view toggling without changing document text or playhead id, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, leftover Play-loop playhead marks after `executeStep`, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index ae463bec..8df90b18 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -119,23 +119,28 @@ The primary workspace is an interactive Gherkin player with a project feature pi ```text +-------------------------------------------------------------------------+ -| Features | Project / readiness Play Pause Stop Player status | -+----------+---------------------------+----------------------------------+ -| Feature /| LIVE GHERKIN EDITOR | Mapping | Terminal | Diagnostic | -| file | (WebView block editor) | WebView tree / typed values | -| picker | highlighted playhead | worker log / retained-run frames | -| + search | | | -+----------+---------------------------+ | -| | Step Editor [Step] [From | | -| | Here] [ live command ] | | -+----------+---------------------------+----------------------------------+ +| Scenarios | Project / readiness Play Pause Stop Player status | ++-----------+--------------------------+----------------------------------+ +| Name + | LIVE GHERKIN EDITOR | Mapping | Terminal | Diagnostic | +| match mode| [Text | Blocks] | WebView tree / typed values | +| tags AND | playhead on same buffer | worker log / retained-run frames | +| tags NOT | | | +| scenario | | | +| list | | | +| (feature | | | +| filter | | | +| hidden) | | | ++-----------+--------------------------+ | +| | Step Editor [Step] [From | | +| | Here] [ live command ] | | ++-----------+--------------------------+----------------------------------+ | Footer activity | +-------------------------------------------------------------------------+ ``` -The expandable left rail lists feature files from the synchronized consumer project. Browse by Gherkin Feature name or by file name + directory path. Click toggles feature selection. The scenario list is limited to selected features, or to every project scenario when nothing is selected; the search bar filters that list. Clicking a scenario loads it into the live session buffer. Workbench does not write `.feature` files unless you use the explicit **Save** control. +The left rail is a scenario filter, not a feature-file browser. Primary controls are scenario name (starts with / contains / ends with / full match; default contains; all four are case-insensitive against the Gherkin Scenario / Scenario Outline title), tags the scenario must have (AND), and tags it must not have (NOT). Include/exclude fields accept any number of tags, with or without a leading `@`, split on commas and/or whitespace. Empty include/exclude means no tag constraint. Feature-level tags, optional Rule tags, the scenario/outline's own tags, and Examples tags on an outline are inherited the same way Cucumber does; Workbench parses those tags from the catalog `.feature` files and does not call Cucumber. Feature-file selection is collapsed behind **Filter by feature** (Gherkin Feature name vs file path lives in that panel). Default: no feature filter, so name/tag apply to every catalog scenario in the synchronized project. Clicking a result still loads that scenario into the live session buffer. Workbench does not write `.feature` files unless you use the explicit **Save** control. -The center editor is an embedded HTML/JS block editor hosted in JavaFX `WebView` (`JFXPanel`). Blocks are Gherkin text — including `Given` / `When` / `Then` — not a second language compiled to Gherkin. Nested steps and `IF` / `ELSE` blocks snap as parent/child using Pickleball's leading-colon grammar. The play header is unchanged: click-to-seek, global **Play** from the first step in a fresh worker context, **Step** = isolated `executeStep`, **From Here** = selected/playhead through the rest, stay in play at end, Enter append-and-run. JavaScript never executes Gherkin. +The center editor shows the same `LiveScenarioPlayer` buffer as ordinary Gherkin text or as the embedded HTML/JS block editor hosted in JavaFX `WebView` (`JFXPanel`). A prominent **Text | Blocks** toggle next to the editor heading switches views without losing playhead, selection, or document text. Blocks are Gherkin text — including `Given` / `When` / `Then` — not a second language compiled to Gherkin. Nested steps and `IF` / `ELSE` blocks snap as parent/child using Pickleball's leading-colon grammar. The play header is unchanged: click-to-seek, global **Play** from the first step in a fresh worker context, **Step** = isolated `executeStep`, **From Here** = selected/playhead through the rest, stay in play at end, Enter append-and-run. JavaScript never executes Gherkin. If JavaFX cannot start, Text is already the fallback and Blocks stays disabled/unavailable. The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls stay under **Session**. Existing investigation tools stay under **Tools > Advanced Controls**. @@ -202,7 +207,7 @@ NodeMap implementations that are not exact ordinary `NodeMap` instances remain i ### WebView packaging -JDK 21 does not ship a modern browser panel. Workbench embeds OpenJFX `WebView` through `JFXPanel` for the Gherkin editor, Mapping tree, and Diagnostic explorer. That choice stays Workbench-only: Maven-central JavaFX modules are shaded into the controller executable, including platform natives under `javafx-natives//`. JCEF was not used because Chromium natives are harder to keep isolation-clean and do not package as ordinary Workbench dependencies. If JavaFX cannot start, the live editor falls back to the existing in-place text buffer on the same `LiveScenarioPlayer` model. +JDK 21 does not ship a modern browser panel. Workbench embeds OpenJFX `WebView` through `JFXPanel` for the Gherkin editor, Mapping tree, and Diagnostic explorer. That choice stays Workbench-only: Maven-central JavaFX modules are shaded into the controller executable, including platform natives under `javafx-natives//`. JCEF was not used because Chromium natives are harder to keep isolation-clean and do not package as ordinary Workbench dependencies. If JavaFX cannot start, the live editor stays on the existing in-place text buffer on the same `LiveScenarioPlayer` model; the **Text | Blocks** toggle remains visible and Blocks is disabled so the fallback is honest. ### Terminal and Diagnostic Log Explorer @@ -251,7 +256,7 @@ Workbench owns one control lease for the live session. Swing and MCP/HTTP adapte While the human holds the lease, Swing play/edit/mapping/save/worker controls stay enabled. Agent mutating calls fail clearly until `workbench_request_control`. -While an agent holds the lease, the human can watch the same window. Play, edit, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. +While an agent holds the lease, the human can watch the same window. Play, edit, picker/filter, editor view toggle, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. The agent should update `currentAction` as it works. Playhead, Mapping, Terminal, and screenshots follow because the agent uses the same `LiveScenarioPlayer` / worker as the UI. Testing the live scenario (`executeStep`, play, Mapping reads, evidence) is allowed on the agent lease. Copying the live scenario into the original `.feature` is not; that goes through `workbench_request_save` and waits for Allow/Deny in the Swing banner. @@ -433,24 +438,28 @@ java -jar $workbenchJar ui ".\maven-consumer-project" Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. -1. Verify the top-level layout has an expandable Features picker, the Live Gherkin Editor and compact Step Editor in the center, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. +1. Verify the top-level layout has a scenario name/tag filter rail (feature-file filter collapsed), the Live Gherkin Editor with a **Text | Blocks** toggle and compact Step Editor in the center, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. 2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site when no picker scenario is selected. -3. Click different scenario blocks and verify the playhead highlight moves immediately to the clicked step. -4. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. -5. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. -6. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. -7. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. -8. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. -9. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. -10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Top-level properties come from the worker ParsingMap and accept typed in-place edits. -11. Verify Terminal filters worker log files by level and continues as steps run, without writing to MCP stdout. -12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. -13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. -14. Verify blocking runtime actions leave the Swing UI responsive. -15. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. -16. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/mapping/save/worker controls. **Take control** remains enabled. -17. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. -18. The default demo remains unsavable for both human Save and agent `workbench_request_save`. +3. Filter scenarios by name using contains (default) and the other match modes; confirm matching is case-insensitive and applies to the Scenario / Scenario Outline title. +4. Filter with include tags (AND) and exclude tags (NOT), with and without `@`, and confirm Feature-level tags apply to scenarios in that feature. +5. Confirm **Filter by feature** is collapsed by default and that name/tag filters then apply to every catalog scenario. Opening it still supports multi-select and Feature name vs file path. +6. Click a filtered scenario and verify it loads into the live buffer. Switch **Text | Blocks** and verify playhead, selection, and document text are unchanged. If WebView is unavailable, Blocks is disabled and Text remains the editor. +7. Click different scenario blocks/lines and verify the playhead highlight moves immediately to the clicked step. +8. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. +9. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. +10. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. +11. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. +12. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. +13. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. +14. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Top-level properties come from the worker ParsingMap and accept typed in-place edits. +15. Verify Terminal filters worker log files by level and continues as steps run, without writing to MCP stdout. +16. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. +17. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +18. Verify blocking runtime actions leave the Swing UI responsive. +19. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. +20. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/picker/filter/editor-view/mapping/save/worker controls. **Take control** remains enabled. +21. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. +22. The default demo remains unsavable for both human Save and agent `workbench_request_save`. ## Regression diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 5ceeedb4..201ad4bf 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -66,8 +66,8 @@ java -jar pickleball-workbench-.jar ui The UI is player-style and execution-oriented. Its primary layout is: ```text -left rail: project feature / scenario picker -center: Live Gherkin block editor (JavaFX WebView) + compact Step Editor / Command +left rail: scenario name/tag filters + results; optional feature-file filter +center: Live Gherkin editor (Text | Blocks) + compact Step Editor / Command right: Mapping | Terminal | Diagnostic Log Explorer ``` @@ -75,7 +75,7 @@ Low-level lifecycle controls live under the Session menu and existing investigat `LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, the editable Gherkin document, selected line, playhead, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. -The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless **Save** is explicitly approved. Human Save asks before copying the live scenario into the original scenario in the original `.feature` file. An attached agent must use `workbench_request_save` and wait for Allow/Deny when the UI is present. Deny and Take control write nothing. WebView JavaScript must not execute Gherkin. +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer after filtering by scenario name (starts with / contains / ends with / full match; default contains; all case-insensitive) and Cucumber tags (include AND, exclude NOT, with Feature/Rule/outline/Examples inheritance parsed from the `.feature` files). Feature-file selection is a collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless **Save** is explicitly approved. Human Save asks before copying the live scenario into the original scenario in the original `.feature` file. An attached agent must use `workbench_request_save` and wait for Allow/Deny when the UI is present. Deny and Take control write nothing. A prominent **Text | Blocks** toggle shows the same live buffer as ordinary Gherkin or as the WebView block editor without losing playhead, selection, or document text. If JavaFX/WebView is unavailable, Text is the fallback and Blocks stays honestly unavailable. WebView JavaScript must not execute Gherkin. The Step Editor has two play actions: **Step** executes only the editor text through `WorkbenchServices.executeStep` and leaves automatic playback paused; **From Here** restarts into a fresh scenario context and runs from the selected/playhead step through the rest of the buffer. Enter while waiting at end appends the step and continues the live run. Do not strip Gherkin keywords or add a Swing-side step matcher. Worker-side `DynamicControl` / `GherkinControl` remain the only Gherkin interpreters. @@ -93,7 +93,7 @@ Existing capabilities remain available: project/synchronization status, worker l The Swing Mapping put control sends entered values as text; it does not create a second Mapping parser or state model. Step Override source is sent unchanged to worker-side compilation and must contain `{{CLASS_NAME}}`; the UI must never compile handlers in the controller JVM. Browser/service/screenshot controls only present bridge evidence already supplied by Pickleball. Breakpoint controls delegate the hook/filter/lease contract to the shared service and must not recreate coordinator semantics. -Blocking synchronization, process, bridge, Mapping, event, screenshot, service-call, Step Override, and breakpoint actions must not run on the Swing Event Dispatch Thread. Live controls must target the controller-owned running/paused worker. Semantic-event cursors are worker-local and must reset when a fresh worker is started/restarted. Prefer headless-safe tests around player state and presentation/controller delegation rather than tests requiring a visible desktop. +Blocking synchronization, process, bridge, Mapping, event, screenshot, service-call, Step Override, and breakpoint actions must not run on the Swing Event Dispatch Thread. Live controls must target the controller-owned running/paused worker. When an agent holds the control lease, lock picker/filter fields, scenario list, feature-filter disclosure, the Text | Blocks toggle, and the live editor the same way other play/edit controls lock. Semantic-event cursors are worker-local and must reset when a fresh worker is started/restarted. Prefer headless-safe tests around player state, catalog/filter models, editor-view toggling, and presentation/controller delegation rather than tests requiring a visible desktop. ## MCP stdio diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java index c2621873..b05f6e26 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java @@ -10,7 +10,6 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -24,8 +23,10 @@ * value when it is already present in project-owned configuration. It does * not crawl a git worktree or invent a second project model.

* - *

Feature/Scenario titles are read as Gherkin structure labels for browsing - * only. This class does not match or execute steps.

+ *

Feature/Scenario titles and Gherkin tags are read as structure labels for + * browsing only. Feature, Rule, scenario/outline, and Examples tags are + * inherited the same way Cucumber does. This class does not match or execute + * steps and does not call Cucumber.

*/ public final class ConsumerFeatureCatalog { public enum BrowseMode { @@ -39,7 +40,8 @@ public record FeatureEntry( String featureName, String directoryPath, String fileName, - List scenarios + List scenarios, + List tags ) { public FeatureEntry { Objects.requireNonNull(file, "file"); @@ -48,6 +50,7 @@ public record FeatureEntry( directoryPath = directoryPath == null ? "" : directoryPath; fileName = fileName == null ? file.getFileName().toString() : fileName; scenarios = List.copyOf(scenarios == null ? List.of() : scenarios); + tags = ScenarioFilter.copyTags(tags); } public String browseLabel(BrowseMode mode) { @@ -65,7 +68,9 @@ public record ScenarioEntry( String relativePath, int startLine, int endLine, - List lines + List lines, + List tags, + List effectiveTags ) { public ScenarioEntry { name = name == null ? "" : name; @@ -73,6 +78,8 @@ public record ScenarioEntry( Objects.requireNonNull(file, "file"); relativePath = relativePath == null ? "" : relativePath; lines = List.copyOf(lines == null ? List.of() : lines); + tags = ScenarioFilter.copyTags(tags); + effectiveTags = ScenarioFilter.copyTags(effectiveTags); } public String displayLabel() { @@ -83,8 +90,8 @@ public String displayLabel() { private final Path projectRoot; private final List features; private final Set selectedFeatures = new LinkedHashSet<>(); + private final ScenarioFilter filter = new ScenarioFilter(); private BrowseMode browseMode = BrowseMode.FEATURE_NAME; - private String scenarioQuery = ""; public ConsumerFeatureCatalog(Path projectRoot, WorkbenchManifest manifest) { this(projectRoot, discover(projectRoot, manifest)); @@ -111,12 +118,16 @@ public void setBrowseMode(BrowseMode browseMode) { this.browseMode = browseMode == null ? BrowseMode.FEATURE_NAME : browseMode; } + public ScenarioFilter filter() { + return filter; + } + public String scenarioQuery() { - return scenarioQuery; + return filter.nameQuery(); } public void setScenarioQuery(String scenarioQuery) { - this.scenarioQuery = scenarioQuery == null ? "" : scenarioQuery; + filter.setNameQuery(scenarioQuery); } public List features() { @@ -163,20 +174,26 @@ public List selectedFeatureFiles() { } /** - * Scenarios under the selected features, or every project scenario when - * nothing is selected. The search bar then filters that list. + * Scenarios that pass the optional feature-file filter. With no feature + * selected, every catalog scenario is a candidate; name/tag filters then + * apply to that pool. */ - public List visibleScenarios() { + public List candidateScenarios() { List pool = new ArrayList<>(); for (FeatureEntry feature : features) { if (selectedFeatures.isEmpty() || selectedFeatures.contains(feature.file())) { pool.addAll(feature.scenarios()); } } - String query = scenarioQuery.strip().toLowerCase(Locale.ROOT); - if (!query.isEmpty()) { - pool.removeIf(scenario -> !matches(scenario, query)); - } + return List.copyOf(pool); + } + + /** + * Candidate scenarios after the primary name/tag filter. Feature-file + * selection is optional and does not restrict the pool when empty. + */ + public List visibleScenarios() { + List pool = new ArrayList<>(filter.apply(candidateScenarios())); pool.sort(Comparator .comparing(ScenarioEntry::featureName, String.CASE_INSENSITIVE_ORDER) .thenComparing(ScenarioEntry::name, String.CASE_INSENSITIVE_ORDER)); @@ -285,27 +302,68 @@ static Path resolveConfiguredFeatures(Path projectRoot, String value) { static FeatureEntry readFeature(Path projectRoot, Path file) throws IOException { List lines = Files.readAllLines(file, StandardCharsets.UTF_8); String featureName = ""; + List featureTags = List.of(); + List ruleTags = List.of(); + List pendingTags = new ArrayList<>(); String currentScenario = null; int scenarioStart = -1; + boolean currentIsOutline = false; + List currentOwnTags = List.of(); + LinkedHashSet currentExampleTags = new LinkedHashSet<>(); List header = new ArrayList<>(); List scenarios = new ArrayList<>(); for (int i = 0; i < lines.size(); i++) { String trimmed = lines.get(i).strip(); + if (ScenarioFilter.isGherkinTagLine(trimmed)) { + pendingTags.addAll(ScenarioFilter.parseGherkinTagLine(trimmed)); + continue; + } + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } if (startsWithKeyword(trimmed, "Feature:")) { featureName = trimmed.substring("Feature:".length()).strip(); + featureTags = List.copyOf(pendingTags); + ruleTags = List.of(); + pendingTags.clear(); header.add(lines.get(i)); - } else if (startsWithKeyword(trimmed, "Scenario Outline:") || startsWithKeyword(trimmed, "Scenario:")) { + } else if (startsWithKeyword(trimmed, "Rule:")) { + ruleTags = List.copyOf(pendingTags); + pendingTags.clear(); + } else if (startsWithKeyword(trimmed, "Scenario Outline:") + || startsWithKeyword(trimmed, "Scenario Template:") + || startsWithKeyword(trimmed, "Scenario:")) { if (currentScenario != null) { - scenarios.add(scenario(projectRoot, file, featureName, currentScenario, scenarioStart, i - 1, lines, header)); + scenarios.add(scenario( + projectRoot, file, featureName, featureTags, ruleTags, + currentScenario, currentOwnTags, currentExampleTags, + scenarioStart, i - 1, lines, header + )); } + currentIsOutline = startsWithKeyword(trimmed, "Scenario Outline:") + || startsWithKeyword(trimmed, "Scenario Template:"); currentScenario = trimmed.contains(":") ? trimmed.substring(trimmed.indexOf(':') + 1).strip() : trimmed; scenarioStart = i; + currentOwnTags = List.copyOf(pendingTags); + currentExampleTags.clear(); + pendingTags.clear(); + } else if (startsWithKeyword(trimmed, "Examples:") || startsWithKeyword(trimmed, "Example:")) { + if (currentIsOutline) { + currentExampleTags.addAll(pendingTags); + } + pendingTags.clear(); + } else { + pendingTags.clear(); } } if (currentScenario != null) { - scenarios.add(scenario(projectRoot, file, featureName, currentScenario, scenarioStart, lines.size() - 1, lines, header)); + scenarios.add(scenario( + projectRoot, file, featureName, featureTags, ruleTags, + currentScenario, currentOwnTags, currentExampleTags, + scenarioStart, lines.size() - 1, lines, header + )); } Path relative = relativeTo(projectRoot, file); String relativePath = relative.toString().replace('\\', '/'); @@ -316,7 +374,8 @@ static FeatureEntry readFeature(Path projectRoot, Path file) throws IOException featureName, parent == null ? "" : parent.toString().replace('\\', '/'), file.getFileName().toString(), - scenarios + scenarios, + featureTags ); } @@ -324,7 +383,11 @@ private static ScenarioEntry scenario( Path projectRoot, Path file, String featureName, + List featureTags, + List ruleTags, String name, + List ownTags, + Set exampleTags, int start, int end, List lines, @@ -341,6 +404,11 @@ private static ScenarioEntry scenario( while (!body.isEmpty() && body.getLast().isBlank()) { body.removeLast(); } + LinkedHashSet effective = new LinkedHashSet<>(); + effective.addAll(featureTags); + effective.addAll(ruleTags); + effective.addAll(ownTags); + if (exampleTags != null) effective.addAll(exampleTags); return new ScenarioEntry( name, featureName, @@ -348,16 +416,12 @@ private static ScenarioEntry scenario( relativeTo(projectRoot, file).toString().replace('\\', '/'), start + 1, end + 1, - body + body, + ownTags, + List.copyOf(effective) ); } - private static boolean matches(ScenarioEntry scenario, String query) { - return scenario.name().toLowerCase(Locale.ROOT).contains(query) - || scenario.featureName().toLowerCase(Locale.ROOT).contains(query) - || scenario.relativePath().toLowerCase(Locale.ROOT).contains(query); - } - private static boolean startsWithKeyword(String trimmed, String keyword) { return trimmed.startsWith(keyword); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java new file mode 100644 index 00000000..0dabdb79 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java @@ -0,0 +1,232 @@ +package tools.dscode.workbench.catalog; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Headless name/tag filter for catalog scenarios. + * + *

Name matching is always case-insensitive and applies only to the Gherkin + * {@code Scenario} / {@code Scenario Outline} title. Tag matching follows + * Cucumber inheritance already materialized on {@link ConsumerFeatureCatalog.ScenarioEntry#effectiveTags()}: + * feature tags, optional Rule tags, the scenario/outline's own tags, and + * Examples tags on an outline. Include tags are AND; exclude tags are NOT + * (any listed exclude tag drops the scenario). Empty include/exclude means + * no tag constraint. Tag queries accept values with or without a leading + * {@code @} and split on commas and/or whitespace. Tag comparison is + * case-sensitive after {@code @} normalization, matching Cucumber.

+ */ +public final class ScenarioFilter { + public enum NameMatchMode { + STARTS_WITH("Starts with"), + CONTAINS("Contains"), + ENDS_WITH("Ends with"), + FULL_MATCH("Full match"); + + private final String label; + + NameMatchMode(String label) { + this.label = label; + } + + public String label() { + return label; + } + + @Override + public String toString() { + return label; + } + } + + public static final NameMatchMode DEFAULT_NAME_MATCH = NameMatchMode.CONTAINS; + + private NameMatchMode nameMatchMode = DEFAULT_NAME_MATCH; + private String nameQuery = ""; + private String includeTagsQuery = ""; + private String excludeTagsQuery = ""; + + public NameMatchMode nameMatchMode() { + return nameMatchMode; + } + + public void setNameMatchMode(NameMatchMode nameMatchMode) { + this.nameMatchMode = nameMatchMode == null ? DEFAULT_NAME_MATCH : nameMatchMode; + } + + public String nameQuery() { + return nameQuery; + } + + public void setNameQuery(String nameQuery) { + this.nameQuery = nameQuery == null ? "" : nameQuery; + } + + public String includeTagsQuery() { + return includeTagsQuery; + } + + public void setIncludeTagsQuery(String includeTagsQuery) { + this.includeTagsQuery = includeTagsQuery == null ? "" : includeTagsQuery; + } + + public String excludeTagsQuery() { + return excludeTagsQuery; + } + + public void setExcludeTagsQuery(String excludeTagsQuery) { + this.excludeTagsQuery = excludeTagsQuery == null ? "" : excludeTagsQuery; + } + + public void copyFrom(ScenarioFilter other) { + if (other == null) return; + this.nameMatchMode = other.nameMatchMode; + this.nameQuery = other.nameQuery; + this.includeTagsQuery = other.includeTagsQuery; + this.excludeTagsQuery = other.excludeTagsQuery; + } + + public List includeTags() { + return parseTagQuery(includeTagsQuery); + } + + public List excludeTags() { + return parseTagQuery(excludeTagsQuery); + } + + public List apply( + List scenarios + ) { + List source = + scenarios == null ? List.of() : scenarios; + List include = includeTags(); + List exclude = excludeTags(); + List matched = new ArrayList<>(); + for (ConsumerFeatureCatalog.ScenarioEntry scenario : source) { + if (matches(scenario, include, exclude)) { + matched.add(scenario); + } + } + return List.copyOf(matched); + } + + boolean matches(ConsumerFeatureCatalog.ScenarioEntry scenario) { + return matches(scenario, includeTags(), excludeTags()); + } + + private boolean matches( + ConsumerFeatureCatalog.ScenarioEntry scenario, + List include, + List exclude + ) { + if (scenario == null) return false; + if (!nameMatches(scenario.name())) return false; + Set effective = canonicalTagSet(scenario.effectiveTags()); + if (!include.isEmpty() && !effective.containsAll(include)) return false; + if (!exclude.isEmpty()) { + for (String tag : exclude) { + if (effective.contains(tag)) return false; + } + } + return true; + } + + private boolean nameMatches(String name) { + String query = nameQuery.strip(); + if (query.isEmpty()) return true; + String haystack = name == null ? "" : name; + String a = haystack.toLowerCase(Locale.ROOT); + String b = query.toLowerCase(Locale.ROOT); + return switch (nameMatchMode) { + case STARTS_WITH -> a.startsWith(b); + case CONTAINS -> a.contains(b); + case ENDS_WITH -> a.endsWith(b); + case FULL_MATCH -> a.equals(b); + }; + } + + /** + * Splits a free-text tag field on commas and/or whitespace and strips a + * leading {@code @} from each token. Empty input is no constraint. + */ + public static List parseTagQuery(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + LinkedHashSet tags = new LinkedHashSet<>(); + for (String part : raw.split("[,\\s]+")) { + String canonical = canonicalTag(part); + if (!canonical.isEmpty()) tags.add(canonical); + } + return List.copyOf(tags); + } + + public static String canonicalTag(String raw) { + if (raw == null) return ""; + String tag = raw.strip(); + while (tag.startsWith("@")) { + tag = tag.substring(1).strip(); + } + return tag; + } + + static Set canonicalTagSet(Collection tags) { + LinkedHashSet canonical = new LinkedHashSet<>(); + if (tags == null) return canonical; + for (String tag : tags) { + String value = canonicalTag(tag); + if (!value.isEmpty()) canonical.add(value); + } + return canonical; + } + + static List copyTags(Collection tags) { + return List.copyOf(canonicalTagSet(tags)); + } + + static List parseGherkinTagLine(String trimmed) { + if (trimmed == null || !isGherkinTagLine(trimmed)) return List.of(); + LinkedHashSet tags = new LinkedHashSet<>(); + for (String token : trimmed.split("\\s+")) { + String canonical = canonicalTag(token); + if (!canonical.isEmpty()) tags.add(canonical); + } + return List.copyOf(tags); + } + + static boolean isGherkinTagLine(String trimmed) { + if (trimmed == null || trimmed.isEmpty() || !trimmed.startsWith("@")) return false; + String[] tokens = trimmed.split("\\s+"); + if (tokens.length == 0) return false; + for (String token : tokens) { + if (!token.startsWith("@") || canonicalTag(token).isEmpty()) return false; + } + return true; + } + + @Override + public String toString() { + return "ScenarioFilter{mode=" + nameMatchMode + + ", name='" + nameQuery + + "', include='" + includeTagsQuery + + "', exclude='" + excludeTagsQuery + "'}"; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ScenarioFilter that)) return false; + return nameMatchMode == that.nameMatchMode + && Objects.equals(nameQuery, that.nameQuery) + && Objects.equals(includeTagsQuery, that.includeTagsQuery) + && Objects.equals(excludeTagsQuery, that.excludeTagsQuery); + } + + @Override + public int hashCode() { + return Objects.hash(nameMatchMode, nameQuery, includeTagsQuery, excludeTagsQuery); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java new file mode 100644 index 00000000..bb959736 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java @@ -0,0 +1,66 @@ +package tools.dscode.workbench.player; + +/** + * Headless Text vs Blocks presentation choice for the live scenario editor. + * + *

The view mode is independent of {@link LiveScenarioPlayer}: switching + * does not change document text, line identities, selection, or playhead. + * When JavaFX {@code WebView} is unavailable, block view is honestly + * unavailable and the live buffer stays on the existing text fallback.

+ */ +public final class LiveEditorView { + public enum Mode { + TEXT, + BLOCKS + } + + private final boolean blocksAvailable; + private Mode mode; + + public LiveEditorView(boolean blocksAvailable) { + this.blocksAvailable = blocksAvailable; + this.mode = blocksAvailable ? Mode.BLOCKS : Mode.TEXT; + } + + public static LiveEditorView blocksAvailable() { + return new LiveEditorView(true); + } + + public static LiveEditorView blocksUnavailable() { + return new LiveEditorView(false); + } + + public boolean blocksAvailable() { + return blocksAvailable; + } + + public Mode mode() { + return mode; + } + + public boolean showingBlocks() { + return mode == Mode.BLOCKS; + } + + /** + * Selects Text or Blocks. Requests for Blocks when the WebView host is + * unavailable are ignored and return {@code false}; the view stays Text. + */ + public boolean setMode(Mode requested) { + Mode next = requested == null ? mode : requested; + if (next == Mode.BLOCKS && !blocksAvailable) { + mode = Mode.TEXT; + return false; + } + mode = next; + return true; + } + + public boolean showText() { + return setMode(Mode.TEXT); + } + + public boolean showBlocks() { + return setMode(Mode.BLOCKS); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java index 20706e5b..713197d1 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java @@ -1,10 +1,14 @@ package tools.dscode.workbench.ui; import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; +import tools.dscode.workbench.catalog.ScenarioFilter; import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; import javax.swing.DefaultListModel; import javax.swing.JButton; +import javax.swing.JComboBox; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JPanel; @@ -17,44 +21,48 @@ import javax.swing.event.DocumentListener; import java.awt.BorderLayout; import java.awt.Dimension; -import java.awt.FlowLayout; import java.awt.GridLayout; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.function.Consumer; -/** Expandable left-rail feature/scenario picker over the project-owned catalog. */ +/** + * Left-rail scenario picker. Name and tag filters are the primary controls; + * feature-file selection is a collapsed secondary filter. + */ final class FeaturePickerPanel extends JPanel { private final DefaultListModel featureModel = new DefaultListModel<>(); private final DefaultListModel scenarioModel = new DefaultListModel<>(); private final JList featureList = new JList<>(featureModel); private final JList scenarioList = new JList<>(scenarioModel); - private final JTextField search = new JTextField(); + private final JTextField nameField = new JTextField(); + private final JComboBox nameMatchMode = + new JComboBox<>(ScenarioFilter.NameMatchMode.values()); + private final JTextField includeTags = new JTextField(); + private final JTextField excludeTags = new JTextField(); private final JToggleButton featureNameMode = new JToggleButton("Feature name"); private final JToggleButton filePathMode = new JToggleButton("File path"); + private final JToggleButton featureFilterToggle = new JToggleButton("Filter by feature"); + private final JPanel featureFilterPanel = new JPanel(new BorderLayout(0, 4)); private final JButton saveButton = WorkbenchTheme.flatButton("Save", "Write the live buffer back to the loaded .feature file"); - private final JLabel status = WorkbenchTheme.muted("No features selected — search all project scenarios"); + private final JLabel status = WorkbenchTheme.muted("Showing all project scenarios"); private ConsumerFeatureCatalog catalog; - private final Map featuresByPath = new LinkedHashMap<>(); private Consumer onScenario; private Runnable onSave; - private boolean syncing; + private boolean saveEnabled; private boolean locked; FeaturePickerPanel() { super(new BorderLayout(8, 8)); WorkbenchTheme.surface(this); setBorder(WorkbenchTheme.cardBorder()); - setPreferredSize(new Dimension(280, 640)); - setMinimumSize(new Dimension(220, 320)); + setPreferredSize(new Dimension(300, 640)); + setMinimumSize(new Dimension(240, 320)); - add(header(), BorderLayout.NORTH); - add(lists(), BorderLayout.CENTER); - add(status, BorderLayout.SOUTH); + add(northChrome(), BorderLayout.NORTH); + add(labeled("Scenarios", scenarioList), BorderLayout.CENTER); + add(southChrome(), BorderLayout.SOUTH); featureList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); featureList.setCellRenderer((list, value, index, selected, focused) -> { @@ -73,10 +81,14 @@ final class FeaturePickerPanel extends JPanel { label.setBorder(new EmptyBorder(4, 6, 4, 6)); label.setBackground(selected ? WorkbenchTheme.PLAYHEAD : WorkbenchTheme.SURFACE); label.setForeground(WorkbenchTheme.TEXT); - label.setToolTipText(value == null ? null : value.featureName() + " — " + value.relativePath()); + if (value != null) { + String tags = value.effectiveTags().isEmpty() + ? "" + : " @" + String.join(" @", value.effectiveTags()); + label.setToolTipText(value.featureName() + " — " + value.relativePath() + tags); + } return label; }); - featureList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); featureList.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent event) { @@ -97,27 +109,41 @@ public void mousePressed(MouseEvent event) { ConsumerFeatureCatalog.ScenarioEntry selected = scenarioList.getSelectedValue(); if (selected != null && onScenario != null && !locked) onScenario.accept(selected); }); - search.getDocument().addDocumentListener(new DocumentListener() { - @Override public void insertUpdate(DocumentEvent event) { filterChanged(); } - @Override public void removeUpdate(DocumentEvent event) { filterChanged(); } - @Override public void changedUpdate(DocumentEvent event) { filterChanged(); } - }); + nameMatchMode.setSelectedItem(ScenarioFilter.DEFAULT_NAME_MATCH); + nameMatchMode.setToolTipText("Case-insensitive match against the Scenario / Scenario Outline title."); + nameMatchMode.addActionListener(event -> filterChanged()); + listen(nameField); + listen(includeTags); + listen(excludeTags); + nameField.setToolTipText("Filter by scenario name. All four match modes are case-insensitive."); + includeTags.setToolTipText("Tags the scenario must have (AND). With or without @; split on commas or spaces. Empty means no include constraint."); + excludeTags.setToolTipText("Tags the scenario must not have (NOT). Any listed tag drops it. Empty means no exclude constraint."); saveButton.setEnabled(false); saveButton.addActionListener(event -> { if (onSave != null && !locked) onSave.run(); }); + featureFilterToggle.setFocusable(false); + featureFilterToggle.setToolTipText("Optional. Leave collapsed to apply name/tag filters to every catalog scenario."); + featureFilterToggle.addActionListener(event -> { + featureFilterPanel.setVisible(featureFilterToggle.isSelected()); + revalidate(); + repaint(); + }); + featureNameMode.setSelected(true); + featureNameMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME)); + filePathMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FILE_PATH)); + featureFilterPanel.setVisible(false); } void setCatalog(ConsumerFeatureCatalog catalog) { this.catalog = catalog; - featuresByPath.clear(); featureModel.clear(); if (catalog == null) { refreshScenarios(); return; } + applyFiltersFromUi(); for (ConsumerFeatureCatalog.FeatureEntry feature : catalog.featuresForBrowse()) { - featuresByPath.put(feature.file(), feature); featureModel.addElement(feature); } syncFeatureSelection(); @@ -126,16 +152,11 @@ void setCatalog(ConsumerFeatureCatalog catalog) { private void syncFeatureSelection() { if (catalog == null) return; - syncing = true; - try { - featureList.clearSelection(); - for (int i = 0; i < featureModel.size(); i++) { - if (catalog.selected(featureModel.get(i).file())) { - featureList.addSelectionInterval(i, i); - } + featureList.clearSelection(); + for (int i = 0; i < featureModel.size(); i++) { + if (catalog.selected(featureModel.get(i).file())) { + featureList.addSelectionInterval(i, i); } - } finally { - syncing = false; } } @@ -147,8 +168,6 @@ void onSave(Runnable onSave) { this.onSave = onSave; } - private boolean saveEnabled; - void setSaveEnabled(boolean enabled) { saveEnabled = enabled; saveButton.setEnabled(enabled && !locked); @@ -159,43 +178,86 @@ void setLocked(boolean locked) { saveButton.setEnabled(saveEnabled && !locked); featureList.setEnabled(!locked); scenarioList.setEnabled(!locked); - search.setEnabled(!locked); + nameField.setEnabled(!locked); + nameMatchMode.setEnabled(!locked); + includeTags.setEnabled(!locked); + excludeTags.setEnabled(!locked); + featureFilterToggle.setEnabled(!locked); featureNameMode.setEnabled(!locked); filePathMode.setEnabled(!locked); } - private JPanel header() { - JPanel header = new JPanel(new BorderLayout(6, 6)); + private JPanel northChrome() { + JPanel north = new JPanel(); + north.setOpaque(false); + north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS)); + + JPanel header = new JPanel(new BorderLayout(6, 0)); header.setOpaque(false); - header.add(WorkbenchTheme.heading("Features"), BorderLayout.WEST); + header.add(WorkbenchTheme.heading("Scenarios"), BorderLayout.WEST); + header.add(saveButton, BorderLayout.EAST); + header.setAlignmentX(LEFT_ALIGNMENT); + header.setMaximumSize(new Dimension(Integer.MAX_VALUE, header.getPreferredSize().height + 8)); + north.add(header); + north.add(Box.createVerticalStrut(8)); + + JPanel nameRow = new JPanel(new BorderLayout(6, 0)); + nameRow.setOpaque(false); + nameRow.add(nameField, BorderLayout.CENTER); + nameMatchMode.setMaximumSize(nameMatchMode.getPreferredSize()); + nameRow.add(nameMatchMode, BorderLayout.EAST); + north.add(labeledField("Scenario name", nameRow)); + north.add(Box.createVerticalStrut(6)); + north.add(labeledField("Must have all tags", includeTags)); + north.add(Box.createVerticalStrut(6)); + north.add(labeledField("Must not have tags", excludeTags)); + north.add(Box.createVerticalStrut(8)); + return north; + } + + private JPanel southChrome() { + JPanel south = new JPanel(); + south.setOpaque(false); + south.setLayout(new BoxLayout(south, BoxLayout.Y_AXIS)); + + featureFilterToggle.setAlignmentX(LEFT_ALIGNMENT); + featureFilterToggle.setMaximumSize(new Dimension(Integer.MAX_VALUE, featureFilterToggle.getPreferredSize().height)); + south.add(featureFilterToggle); + south.add(Box.createVerticalStrut(4)); JPanel modes = new JPanel(new GridLayout(1, 2, 4, 0)); modes.setOpaque(false); - featureNameMode.setSelected(true); - featureNameMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME)); - filePathMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FILE_PATH)); modes.add(featureNameMode); modes.add(filePathMode); - header.add(modes, BorderLayout.CENTER); + featureFilterPanel.setOpaque(false); + featureFilterPanel.setAlignmentX(LEFT_ALIGNMENT); + featureFilterPanel.add(modes, BorderLayout.NORTH); + JScrollPane featureScroll = new JScrollPane(featureList); + featureScroll.setBorder(BorderFactory.createLineBorder(WorkbenchTheme.BORDER)); + featureScroll.setPreferredSize(new Dimension(240, 140)); + featureFilterPanel.add(featureScroll, BorderLayout.CENTER); + south.add(featureFilterPanel); + south.add(Box.createVerticalStrut(6)); + status.setAlignmentX(LEFT_ALIGNMENT); + south.add(status); + return south; + } - JPanel south = new JPanel(new BorderLayout(6, 6)); - south.setOpaque(false); - search.setToolTipText("Filter scenarios. With no feature selected this searches the whole consumer project."); - south.add(search, BorderLayout.CENTER); - JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); - actions.setOpaque(false); - actions.add(saveButton); - south.add(actions, BorderLayout.EAST); - header.add(south, BorderLayout.SOUTH); - return header; + private JPanel labeledField(String title, JTextField field) { + JPanel wrap = new JPanel(new BorderLayout(0, 0)); + wrap.setOpaque(false); + wrap.add(field, BorderLayout.CENTER); + return labeledField(title, wrap); } - private JPanel lists() { - JPanel lists = new JPanel(new GridLayout(2, 1, 0, 8)); - lists.setOpaque(false); - lists.add(labeled("Project features", featureList)); - lists.add(labeled("Scenarios", scenarioList)); - return lists; + private JPanel labeledField(String title, JPanel field) { + JPanel panel = new JPanel(new BorderLayout(0, 2)); + panel.setOpaque(false); + panel.setAlignmentX(LEFT_ALIGNMENT); + panel.add(WorkbenchTheme.muted(title), BorderLayout.NORTH); + panel.add(field, BorderLayout.CENTER); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + 18)); + return panel; } private JPanel labeled(String title, JList list) { @@ -217,27 +279,50 @@ private void setMode(ConsumerFeatureCatalog.BrowseMode mode) { } } + private void listen(JTextField field) { + field.getDocument().addDocumentListener(new DocumentListener() { + @Override public void insertUpdate(DocumentEvent event) { filterChanged(); } + @Override public void removeUpdate(DocumentEvent event) { filterChanged(); } + @Override public void changedUpdate(DocumentEvent event) { filterChanged(); } + }); + } + private void filterChanged() { if (catalog == null) return; - catalog.setScenarioQuery(search.getText()); + applyFiltersFromUi(); refreshScenarios(); } + private void applyFiltersFromUi() { + if (catalog == null) return; + ScenarioFilter filter = catalog.filter(); + filter.setNameQuery(nameField.getText()); + Object selected = nameMatchMode.getSelectedItem(); + filter.setNameMatchMode(selected instanceof ScenarioFilter.NameMatchMode mode + ? mode + : ScenarioFilter.DEFAULT_NAME_MATCH); + filter.setIncludeTagsQuery(includeTags.getText()); + filter.setExcludeTagsQuery(excludeTags.getText()); + } + private void refreshScenarios() { scenarioModel.clear(); if (catalog == null) { status.setText("No synchronized consumer project."); return; } + int candidates = catalog.candidateScenarios().size(); for (ConsumerFeatureCatalog.ScenarioEntry scenario : catalog.visibleScenarios()) { scenarioModel.addElement(scenario); } - if (catalog.selectedFeatureFiles().isEmpty()) { - status.setText(catalog.scenarioQuery().isBlank() - ? "No features selected — showing all project scenarios" - : "Searching all project scenarios"); + int visible = scenarioModel.size(); + String featureNote = catalog.selectedFeatureFiles().isEmpty() + ? "all features" + : catalog.selectedFeatureFiles().size() + " feature(s)"; + if (visible == candidates) { + status.setText(visible + " scenario(s) · " + featureNote); } else { - status.setText(catalog.selectedFeatureFiles().size() + " feature(s) selected"); + status.setText(visible + " of " + candidates + " scenario(s) · " + featureNote); } } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index 038e4ff8..0f3bcf90 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -10,6 +10,7 @@ import tools.dscode.workbench.mapping.MappingTreeModel; import tools.dscode.workbench.mapping.MappingValueCodec; import tools.dscode.workbench.mcp.WorkbenchAttachServer; +import tools.dscode.workbench.player.LiveEditorView; import tools.dscode.workbench.player.LivePlaybackCoordinator; import tools.dscode.workbench.player.LiveScenarioPlayer; import tools.dscode.workbench.player.WorkbenchSavePreview; @@ -65,7 +66,11 @@ final class WorkbenchFrame extends JFrame { private WebViewPanel mappingView; private WebViewPanel diagnosticView; private JComponent pickerSplit; - private final JToggleButton pickerToggle = new JToggleButton("Features"); + private JPanel editorHost; + private LiveEditorView editorView = LiveEditorView.blocksUnavailable(); + private final JToggleButton textViewButton = new JToggleButton("Text"); + private final JToggleButton blocksViewButton = new JToggleButton("Blocks"); + private final JToggleButton pickerToggle = new JToggleButton("Scenarios"); private List mappingEntries = List.of(); private MappingTreeModel mappingModel; private DiagnosticEvidenceNavigator diagnosticNavigator; @@ -331,29 +336,82 @@ private JComponent scenarioPanel() { JPanel panel = new JPanel(new BorderLayout(0, 4)); panel.setBackground(WorkbenchTheme.SURFACE); panel.setBorder(WorkbenchTheme.cardBorder()); - panel.add(WorkbenchTheme.heading("Live Scenario Editor"), BorderLayout.NORTH); + + JPanel header = new JPanel(new BorderLayout(8, 0)); + header.setOpaque(false); + header.add(WorkbenchTheme.heading("Live Scenario Editor"), BorderLayout.WEST); + header.add(editorViewToggle(), BorderLayout.EAST); + panel.add(header, BorderLayout.NORTH); scenarioEditor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); scenarioEditor.setLineWrap(false); scenarioEditor.setTabSize(2); - JPanel editorHost = new JPanel(new CardLayout()); + editorHost = new JPanel(new CardLayout()); editorHost.setOpaque(false); editorHost.add(new JScrollPane(scenarioEditor), "text"); if (gherkinView != null) { editorHost.add(gherkinView, "web"); - ((CardLayout) editorHost.getLayout()).show(editorHost, "web"); } panel.add(editorHost, BorderLayout.CENTER); JPanel legend = new JPanel(new FlowLayout(FlowLayout.LEFT, 18, 2)); legend.setOpaque(false); - legend.add(WorkbenchTheme.muted("Blocks are Gherkin text")); + legend.add(WorkbenchTheme.muted("Same live buffer in Text or Blocks")); legend.add(WorkbenchTheme.muted("Play starts from the first step")); - legend.add(WorkbenchTheme.muted("Click a block to move the playhead")); + legend.add(WorkbenchTheme.muted("Click to move the playhead")); panel.add(legend, BorderLayout.SOUTH); + applyEditorView(); return panel; } + private JPanel editorViewToggle() { + JPanel toggle = new JPanel(new GridLayout(1, 2, 0, 0)); + toggle.setOpaque(false); + ButtonGroup group = new ButtonGroup(); + textViewButton.setFocusable(false); + blocksViewButton.setFocusable(false); + textViewButton.setToolTipText("View and edit the live scenario as ordinary Gherkin text."); + blocksViewButton.setToolTipText(editorView.blocksAvailable() + ? "View and edit the same live buffer as Gherkin blocks." + : "Block view requires JavaFX WebView, which is not available in this process."); + textViewButton.addActionListener(event -> { + if (humanControlsLocked()) { + applyEditorView(); + return; + } + editorView.showText(); + applyEditorView(); + }); + blocksViewButton.addActionListener(event -> { + if (humanControlsLocked()) { + applyEditorView(); + return; + } + editorView.showBlocks(); + applyEditorView(); + }); + group.add(textViewButton); + group.add(blocksViewButton); + toggle.add(textViewButton); + toggle.add(blocksViewButton); + toggle.setBorder(BorderFactory.createLineBorder(WorkbenchTheme.BORDER)); + return toggle; + } + + private void applyEditorView() { + if (editorHost == null) return; + boolean blocks = editorView.showingBlocks() && gherkinView != null; + CardLayout cards = (CardLayout) editorHost.getLayout(); + cards.show(editorHost, blocks ? "web" : "text"); + textViewButton.setSelected(!blocks); + blocksViewButton.setSelected(blocks); + blocksViewButton.setEnabled(editorView.blocksAvailable() && !humanControlsLocked()); + textViewButton.setEnabled(!humanControlsLocked()); + if (blocks) { + pushGherkinView(); + } + } + private JComponent stepPanel() { JPanel panel = new JPanel(new BorderLayout(6, 5)); panel.setBorder(BorderFactory.createCompoundBorder( @@ -490,8 +548,9 @@ public void actionPerformed(java.awt.event.ActionEvent event) { private void configureWebViews() { if (!JavaFxSupport.available()) { + editorView = LiveEditorView.blocksUnavailable(); webViewNote.setText("JavaFX WebView unavailable: " + JavaFxSupport.failure() - + ". Using the text fallback. OpenJFX is Workbench-only."); + + ". Using the text fallback. OpenJFX is Workbench-only. Block view is unavailable."); return; } try { @@ -534,11 +593,14 @@ private void configureWebViews() { "diagnosticHost", diagnosticHost ); + editorView = LiveEditorView.blocksAvailable(); } catch (RuntimeException failure) { gherkinView = null; mappingView = null; diagnosticView = null; - webViewNote.setText("JavaFX WebView failed to start: " + failure.getMessage()); + editorView = LiveEditorView.blocksUnavailable(); + webViewNote.setText("JavaFX WebView failed to start: " + failure.getMessage() + + ". Using the text fallback. Block view is unavailable."); } } @@ -1429,12 +1491,15 @@ private void applyLease(WorkbenchControlLeaseSnapshot snapshot) { } picker.setLocked(locked); + pickerToggle.setEnabled(!locked); scenarioEditor.setEditable(!locked); stepText.setEditable(!locked); playButton.setEnabled(!locked); pauseButton.setEnabled(!locked); playerStopButton.setEnabled(!locked); stepOnlyButton.setEnabled(!locked); + textViewButton.setEnabled(!locked); + blocksViewButton.setEnabled(editorView.blocksAvailable() && !locked); takeControlButton.setEnabled(locked); if (locked) { fromHereButton.setEnabled(false); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java index 996485e0..72e6c52b 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java @@ -51,17 +51,149 @@ void discoversFeaturesFromProjectOwnedTestResourcesAndSupportsSelectionSearch() assertEquals(2, catalog.visibleScenarios().size()); assertTrue(catalog.visibleScenarios().stream().allMatch(scenario -> scenario.featureName().equals("Sign in"))); - catalog.setScenarioQuery("locked"); + catalog.filter().setNameQuery("locked"); assertEquals(1, catalog.visibleScenarios().size()); assertEquals("Locked account", catalog.visibleScenarios().getFirst().name()); catalog.clearFeatureSelection(); - catalog.setScenarioQuery("if else"); + catalog.filter().setNameQuery("if else"); assertEquals(1, catalog.visibleScenarios().size()); assertEquals("IF ELSE branch", catalog.visibleScenarios().getFirst().name()); assertTrue(catalog.visibleScenarios().getFirst().lines().getFirst().startsWith("Feature:")); } + @Test + void nameTagAndOptionalFeatureFiltersComposeAndInheritFeatureTags() throws Exception { + Path features = project.resolve("src/test/resources/features"); + Files.createDirectories(features); + Files.writeString(features.resolve("auth.feature"), """ + @feature-auth @shared + Feature: Sign in + + @smoke @login + Scenario: Valid password + Given a user + + @wip + Scenario: Locked account + Given a lock + + @outline + Scenario Outline: Search term + When search + @fast + Examples: + | term | + | a | + @slow @db + Examples: + | term | + | b | + """); + Files.writeString(features.resolve("other.feature"), """ + @other + Feature: Other + + Scenario: Unrelated + Given stay + """); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, null); + assertEquals(4, catalog.candidateScenarios().size()); + assertEquals(4, catalog.visibleScenarios().size()); + + ConsumerFeatureCatalog.ScenarioEntry valid = named(catalog, "Valid password"); + assertEquals(List.of("feature-auth", "shared", "smoke", "login"), valid.effectiveTags()); + assertEquals(List.of("smoke", "login"), valid.tags()); + + ConsumerFeatureCatalog.ScenarioEntry locked = named(catalog, "Locked account"); + assertTrue(locked.effectiveTags().contains("feature-auth")); + assertTrue(locked.effectiveTags().contains("wip")); + + ConsumerFeatureCatalog.ScenarioEntry search = named(catalog, "Search term"); + assertTrue(search.effectiveTags().containsAll(List.of("feature-auth", "shared", "outline", "fast", "slow", "db"))); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.STARTS_WITH); + catalog.filter().setNameQuery("valid"); + assertEquals(List.of("Valid password"), names(catalog)); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.ENDS_WITH); + catalog.filter().setNameQuery("account"); + assertEquals(List.of("Locked account"), names(catalog)); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.FULL_MATCH); + catalog.filter().setNameQuery("search term"); + assertEquals(List.of("Search term"), names(catalog)); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.CONTAINS); + catalog.filter().setNameQuery(""); + catalog.filter().setIncludeTagsQuery("@smoke @login"); + assertEquals(List.of("Valid password"), names(catalog)); + + catalog.filter().setIncludeTagsQuery(""); + catalog.filter().setExcludeTagsQuery("wip"); + assertEquals(List.of("Unrelated", "Search term", "Valid password"), names(catalog)); + + catalog.filter().setIncludeTagsQuery("shared"); + catalog.filter().setExcludeTagsQuery("@wip"); + assertEquals(List.of("Search term", "Valid password"), names(catalog)); + + catalog.filter().setIncludeTagsQuery("@slow"); + catalog.filter().setExcludeTagsQuery(""); + assertEquals(List.of("Search term"), names(catalog)); + + catalog.filter().setIncludeTagsQuery(""); + catalog.filter().setNameQuery("lock"); + assertEquals(List.of("Locked account"), names(catalog)); + + catalog.clearFeatureSelection(); + catalog.filter().setNameQuery(""); + catalog.toggleFeature(features.resolve("other.feature")); + assertEquals(List.of("Unrelated"), names(catalog)); + assertEquals(1, catalog.candidateScenarios().size()); + + catalog.clearFeatureSelection(); + assertEquals(4, catalog.candidateScenarios().size()); + assertEquals(4, catalog.visibleScenarios().size()); + } + + @Test + void ruleTagsAreInheritedWithFeatureTags() throws Exception { + Path features = project.resolve("src/test/resources/features"); + Files.createDirectories(features); + Files.writeString(features.resolve("rules.feature"), """ + @feature-tag + Feature: Rules + + @rule-a + Rule: First + + @own + Scenario: Inside rule + Given stay + """); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, null); + ConsumerFeatureCatalog.ScenarioEntry inside = named(catalog, "Inside rule"); + assertEquals(List.of("own"), inside.tags()); + assertTrue(inside.effectiveTags().containsAll(List.of("feature-tag", "rule-a", "own"))); + catalog.filter().setIncludeTagsQuery("feature-tag rule-a own"); + assertEquals(List.of("Inside rule"), names(catalog)); + } + + private static ConsumerFeatureCatalog.ScenarioEntry named(ConsumerFeatureCatalog catalog, String name) { + return catalog.visibleScenarios().stream() + .filter(scenario -> scenario.name().equals(name)) + .findFirst() + .orElseThrow(); + } + + private static List names(ConsumerFeatureCatalog catalog) { + return catalog.visibleScenarios().stream() + .map(ConsumerFeatureCatalog.ScenarioEntry::name) + .toList(); + } + @Test void prefersClasspathFeaturesConfigurationWithoutCrawlingUnrelatedDirectories() throws Exception { Files.createDirectories(project.resolve("src/test/resources/features")); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java new file mode 100644 index 00000000..e21c5e1b --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java @@ -0,0 +1,125 @@ +package tools.dscode.workbench.catalog; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScenarioFilterTest { + @Test + void nameMatchModesAreCaseInsensitiveAndDefaultToContains() { + ScenarioFilter filter = new ScenarioFilter(); + assertEquals(ScenarioFilter.NameMatchMode.CONTAINS, filter.nameMatchMode()); + + ConsumerFeatureCatalog.ScenarioEntry login = scenario("Valid Login User"); + ConsumerFeatureCatalog.ScenarioEntry logout = scenario("Logout path"); + List pool = List.of(login, logout); + + filter.setNameQuery("LOGIN"); + assertEquals(List.of(login), filter.apply(pool)); + + filter.setNameMatchMode(ScenarioFilter.NameMatchMode.STARTS_WITH); + filter.setNameQuery("valid"); + assertEquals(List.of(login), filter.apply(pool)); + filter.setNameQuery("user"); + assertTrue(filter.apply(pool).isEmpty()); + + filter.setNameMatchMode(ScenarioFilter.NameMatchMode.ENDS_WITH); + filter.setNameQuery("USER"); + assertEquals(List.of(login), filter.apply(pool)); + filter.setNameQuery("valid"); + assertTrue(filter.apply(pool).isEmpty()); + + filter.setNameMatchMode(ScenarioFilter.NameMatchMode.FULL_MATCH); + filter.setNameQuery("valid login user"); + assertEquals(List.of(login), filter.apply(pool)); + filter.setNameQuery("valid login"); + assertTrue(filter.apply(pool).isEmpty()); + } + + @Test + void includeTagsAreAndAndExcludeTagsAreNot() { + ConsumerFeatureCatalog.ScenarioEntry smokeLogin = scenario("A", "smoke", "login"); + ConsumerFeatureCatalog.ScenarioEntry smokeOnly = scenario("B", "smoke"); + ConsumerFeatureCatalog.ScenarioEntry wip = scenario("C", "wip", "login"); + List pool = List.of(smokeLogin, smokeOnly, wip); + + ScenarioFilter filter = new ScenarioFilter(); + filter.setIncludeTagsQuery("@smoke @login"); + assertEquals(List.of(smokeLogin), filter.apply(pool)); + + filter.setIncludeTagsQuery("smoke, login"); + assertEquals(List.of(smokeLogin), filter.apply(pool)); + + filter.setIncludeTagsQuery(""); + filter.setExcludeTagsQuery("@wip"); + assertEquals(List.of(smokeLogin, smokeOnly), filter.apply(pool)); + + filter.setIncludeTagsQuery("login"); + filter.setExcludeTagsQuery("wip"); + assertEquals(List.of(smokeLogin), filter.apply(pool)); + } + + @Test + void emptyNameAndTagFieldsImposeNoConstraint() { + ConsumerFeatureCatalog.ScenarioEntry one = scenario("One", "alpha"); + ConsumerFeatureCatalog.ScenarioEntry two = scenario("Two"); + ScenarioFilter filter = new ScenarioFilter(); + assertEquals(List.of(one, two), filter.apply(List.of(one, two))); + } + + @Test + void nameAndTagFiltersCompose() { + ConsumerFeatureCatalog.ScenarioEntry keep = scenario("Locked account", "wip"); + ConsumerFeatureCatalog.ScenarioEntry otherWip = scenario("Other wip", "wip"); + ConsumerFeatureCatalog.ScenarioEntry lockedClean = scenario("Locked account clean"); + ScenarioFilter filter = new ScenarioFilter(); + filter.setNameQuery("locked"); + filter.setIncludeTagsQuery("wip"); + assertEquals(List.of(keep), filter.apply(List.of(keep, otherWip, lockedClean))); + } + + @Test + void parseTagQueryAcceptsAtSignCommaAndWhitespace() { + assertEquals(List.of("smoke", "login", "wip"), + ScenarioFilter.parseTagQuery(" @smoke, login wip ")); + assertTrue(ScenarioFilter.parseTagQuery("").isEmpty()); + assertTrue(ScenarioFilter.parseTagQuery(" , ").isEmpty()); + assertEquals("smoke", ScenarioFilter.canonicalTag("@@smoke")); + } + + @Test + void tagComparisonIsCaseSensitiveAfterCanonicalizingAt() { + ConsumerFeatureCatalog.ScenarioEntry tagged = scenario("A", "Smoke"); + ScenarioFilter filter = new ScenarioFilter(); + filter.setIncludeTagsQuery("Smoke"); + assertEquals(List.of(tagged), filter.apply(List.of(tagged))); + filter.setIncludeTagsQuery("smoke"); + assertTrue(filter.apply(List.of(tagged)).isEmpty()); + } + + @Test + void gherkinTagLinesRequireAtPrefixedTokens() { + assertTrue(ScenarioFilter.isGherkinTagLine("@smoke @login")); + assertFalse(ScenarioFilter.isGherkinTagLine("Given @not-a-tag-line")); + assertEquals(List.of("smoke", "login"), ScenarioFilter.parseGherkinTagLine("@smoke @login")); + } + + private static ConsumerFeatureCatalog.ScenarioEntry scenario(String name, String... effectiveTags) { + return new ConsumerFeatureCatalog.ScenarioEntry( + name, + "Demo", + Path.of("demo.feature"), + "demo.feature", + 1, + 3, + List.of("Scenario: " + name), + List.of(), + List.of(effectiveTags) + ); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java new file mode 100644 index 00000000..02dce57c --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java @@ -0,0 +1,50 @@ +package tools.dscode.workbench.player; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LiveEditorViewTest { + @Test + void togglingTextAndBlocksDoesNotChangeDocumentTextOrPlayhead() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Given first", + "And second", + "Then third" + )); + long second = player.lines().get(1).id(); + player.clickLine(second); + String document = player.documentText(); + long selected = player.selectedId().orElseThrow(); + + LiveEditorView view = LiveEditorView.blocksAvailable(); + assertEquals(LiveEditorView.Mode.BLOCKS, view.mode()); + assertTrue(view.showText()); + assertEquals(LiveEditorView.Mode.TEXT, view.mode()); + assertEquals(document, player.documentText()); + assertEquals(second, player.playheadId().orElseThrow()); + assertEquals(selected, player.selectedId().orElseThrow()); + + assertTrue(view.showBlocks()); + assertEquals(LiveEditorView.Mode.BLOCKS, view.mode()); + assertEquals(document, player.documentText()); + assertEquals(second, player.playheadId().orElseThrow()); + assertEquals(selected, player.selectedId().orElseThrow()); + assertEquals("And second", player.playheadLine().orElseThrow().text()); + } + + @Test + void unavailableBlocksStayOnTextAndRefuseBlockMode() { + LiveEditorView view = LiveEditorView.blocksUnavailable(); + assertFalse(view.blocksAvailable()); + assertEquals(LiveEditorView.Mode.TEXT, view.mode()); + assertFalse(view.showBlocks()); + assertEquals(LiveEditorView.Mode.TEXT, view.mode()); + assertTrue(view.showText()); + assertEquals(LiveEditorView.Mode.TEXT, view.mode()); + } +} diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index d8772d43..3a2226af 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is the Text vs Blocks presentation choice for that same buffer; toggling it must not change document text, selection, or playhead id. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is Gherkin text or a block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 86a1c5f4..6d0e3fea 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -477,6 +477,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/build.gradle` - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java` @@ -492,6 +493,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` @@ -534,12 +536,14 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js` - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md index 88afe3d8..96578c8b 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md @@ -25,15 +25,17 @@ The player, picker, Mapping editor, Terminal, and Diagnostic explorer do not cha ## Feature and scenario picker -The expandable left rail lists `.feature` files from the synchronized consumer project: manifest source roots, conventional `src/test/resources/features`, live merged `features/` resources, and an explicit project-owned `pkb_features` value when present. It does not crawl an unrelated git worktree. +The left rail lists scenarios from `.feature` files in the synchronized consumer project: manifest source roots, conventional `src/test/resources/features`, live merged `features/` resources, and an explicit project-owned `pkb_features` value when present. It does not crawl an unrelated git worktree. -Toggle browse mode between Gherkin Feature name and file name + directory path. Click selects or deselects features. The scenario list then shows scenarios under the selection. With no feature selected, the search bar filters every scenario in that project catalog. Clicking a scenario loads it into the live editor. The default demo remains loaded until a scenario is chosen. **Save** is the only write-back path. +Name and tag filtering is the primary UI. Type a scenario name and choose a match mode: starts with, contains (default), ends with, or full match. All four modes are case-insensitive and match only the Gherkin `Scenario` / `Scenario Outline` title. Include tags must all be present (AND). Exclude tags drop a scenario if it has any of them (NOT). Tag fields accept values with or without a leading `@` and split on commas and/or whitespace. Empty include/exclude means no tag constraint. Feature, Rule, scenario/outline, and Examples tags are inherited as Cucumber does; Workbench parses them from the same catalog files and does not call Cucumber from the controller JVM. + +Feature-file selection is secondary and collapsed behind **Filter by feature**. With no feature selected, name/tag filters apply to every catalog scenario. Opening that panel still toggles browse mode between Gherkin Feature name and file name + directory path, and click still selects or deselects features. Clicking a scenario loads it into the live editor. The default demo remains loaded until a scenario is chosen. **Save** is the only write-back path. ## Live Scenario Editor -The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Blocks are Gherkin text, including `Given` / `When` / `Then`. Nested steps and `IF` / `ELSE` snap as parent/child using leading colons. Clicking a block instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. +The center editor is an embedded HTML/JS block editor in JavaFX `WebView`, or ordinary Gherkin text on the same `LiveScenarioPlayer` buffer. A prominent **Text | Blocks** toggle next to the editor heading switches those views without losing playhead, selection, or document text. Play, Step, and From Here keep using the same `LiveScenarioPlayer`. Blocks are Gherkin text, including `Given` / `When` / `Then`. Nested steps and `IF` / `ELSE` snap as parent/child using leading colons. Clicking a block or line instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. -Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. +Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text and Blocks is shown as unavailable. The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario and confirm the copy. The default demo is a small browser scenario against the Maven consumer local test site: @@ -114,7 +116,7 @@ The live player is a collaborative testing space, not a second editor. Swing and - A human can work alone: edit/play the live buffer, then **Save** asks before copying into the original scenario in the original `.feature` file. - An agent attaches to the running UI through `.pickleball/workbench/attach.json` (localhost JSON tools over the same `WorkbenchServices`). It must not start a second Workbench JVM or worker. -- After `workbench_request_control`, Swing play/edit/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. +- After `workbench_request_control`, Swing play/edit/picker/filter/editor-view/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. - `workbench_request_save` is the only original-feature write path for the agent. With the UI attached it blocks on Allow/Deny. Deny writes nothing. Headless stdio MCP may hold the lease without a banner; Save is still an explicit tool. See [pickleball-workbench.md](pickleball-workbench.md) for attach discovery, tool names, and stdout rules. @@ -127,7 +129,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, leftover Play-loop playhead marks after `executeStep`, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. +Workbench player/editor unit tests cover picker name/tag/feature filtering (including Feature-level tag inheritance), block buffer ↔ player model, Text | Blocks view toggling without changing document text or playhead id, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, leftover Play-loop playhead marks after `executeStep`, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index ae463bec..8df90b18 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -119,23 +119,28 @@ The primary workspace is an interactive Gherkin player with a project feature pi ```text +-------------------------------------------------------------------------+ -| Features | Project / readiness Play Pause Stop Player status | -+----------+---------------------------+----------------------------------+ -| Feature /| LIVE GHERKIN EDITOR | Mapping | Terminal | Diagnostic | -| file | (WebView block editor) | WebView tree / typed values | -| picker | highlighted playhead | worker log / retained-run frames | -| + search | | | -+----------+---------------------------+ | -| | Step Editor [Step] [From | | -| | Here] [ live command ] | | -+----------+---------------------------+----------------------------------+ +| Scenarios | Project / readiness Play Pause Stop Player status | ++-----------+--------------------------+----------------------------------+ +| Name + | LIVE GHERKIN EDITOR | Mapping | Terminal | Diagnostic | +| match mode| [Text | Blocks] | WebView tree / typed values | +| tags AND | playhead on same buffer | worker log / retained-run frames | +| tags NOT | | | +| scenario | | | +| list | | | +| (feature | | | +| filter | | | +| hidden) | | | ++-----------+--------------------------+ | +| | Step Editor [Step] [From | | +| | Here] [ live command ] | | ++-----------+--------------------------+----------------------------------+ | Footer activity | +-------------------------------------------------------------------------+ ``` -The expandable left rail lists feature files from the synchronized consumer project. Browse by Gherkin Feature name or by file name + directory path. Click toggles feature selection. The scenario list is limited to selected features, or to every project scenario when nothing is selected; the search bar filters that list. Clicking a scenario loads it into the live session buffer. Workbench does not write `.feature` files unless you use the explicit **Save** control. +The left rail is a scenario filter, not a feature-file browser. Primary controls are scenario name (starts with / contains / ends with / full match; default contains; all four are case-insensitive against the Gherkin Scenario / Scenario Outline title), tags the scenario must have (AND), and tags it must not have (NOT). Include/exclude fields accept any number of tags, with or without a leading `@`, split on commas and/or whitespace. Empty include/exclude means no tag constraint. Feature-level tags, optional Rule tags, the scenario/outline's own tags, and Examples tags on an outline are inherited the same way Cucumber does; Workbench parses those tags from the catalog `.feature` files and does not call Cucumber. Feature-file selection is collapsed behind **Filter by feature** (Gherkin Feature name vs file path lives in that panel). Default: no feature filter, so name/tag apply to every catalog scenario in the synchronized project. Clicking a result still loads that scenario into the live session buffer. Workbench does not write `.feature` files unless you use the explicit **Save** control. -The center editor is an embedded HTML/JS block editor hosted in JavaFX `WebView` (`JFXPanel`). Blocks are Gherkin text — including `Given` / `When` / `Then` — not a second language compiled to Gherkin. Nested steps and `IF` / `ELSE` blocks snap as parent/child using Pickleball's leading-colon grammar. The play header is unchanged: click-to-seek, global **Play** from the first step in a fresh worker context, **Step** = isolated `executeStep`, **From Here** = selected/playhead through the rest, stay in play at end, Enter append-and-run. JavaScript never executes Gherkin. +The center editor shows the same `LiveScenarioPlayer` buffer as ordinary Gherkin text or as the embedded HTML/JS block editor hosted in JavaFX `WebView` (`JFXPanel`). A prominent **Text | Blocks** toggle next to the editor heading switches views without losing playhead, selection, or document text. Blocks are Gherkin text — including `Given` / `When` / `Then` — not a second language compiled to Gherkin. Nested steps and `IF` / `ELSE` blocks snap as parent/child using Pickleball's leading-colon grammar. The play header is unchanged: click-to-seek, global **Play** from the first step in a fresh worker context, **Step** = isolated `executeStep`, **From Here** = selected/playhead through the rest, stay in play at end, Enter append-and-run. JavaScript never executes Gherkin. If JavaFX cannot start, Text is already the fallback and Blocks stays disabled/unavailable. The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls stay under **Session**. Existing investigation tools stay under **Tools > Advanced Controls**. @@ -202,7 +207,7 @@ NodeMap implementations that are not exact ordinary `NodeMap` instances remain i ### WebView packaging -JDK 21 does not ship a modern browser panel. Workbench embeds OpenJFX `WebView` through `JFXPanel` for the Gherkin editor, Mapping tree, and Diagnostic explorer. That choice stays Workbench-only: Maven-central JavaFX modules are shaded into the controller executable, including platform natives under `javafx-natives//`. JCEF was not used because Chromium natives are harder to keep isolation-clean and do not package as ordinary Workbench dependencies. If JavaFX cannot start, the live editor falls back to the existing in-place text buffer on the same `LiveScenarioPlayer` model. +JDK 21 does not ship a modern browser panel. Workbench embeds OpenJFX `WebView` through `JFXPanel` for the Gherkin editor, Mapping tree, and Diagnostic explorer. That choice stays Workbench-only: Maven-central JavaFX modules are shaded into the controller executable, including platform natives under `javafx-natives//`. JCEF was not used because Chromium natives are harder to keep isolation-clean and do not package as ordinary Workbench dependencies. If JavaFX cannot start, the live editor stays on the existing in-place text buffer on the same `LiveScenarioPlayer` model; the **Text | Blocks** toggle remains visible and Blocks is disabled so the fallback is honest. ### Terminal and Diagnostic Log Explorer @@ -251,7 +256,7 @@ Workbench owns one control lease for the live session. Swing and MCP/HTTP adapte While the human holds the lease, Swing play/edit/mapping/save/worker controls stay enabled. Agent mutating calls fail clearly until `workbench_request_control`. -While an agent holds the lease, the human can watch the same window. Play, edit, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. +While an agent holds the lease, the human can watch the same window. Play, edit, picker/filter, editor view toggle, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. The agent should update `currentAction` as it works. Playhead, Mapping, Terminal, and screenshots follow because the agent uses the same `LiveScenarioPlayer` / worker as the UI. Testing the live scenario (`executeStep`, play, Mapping reads, evidence) is allowed on the agent lease. Copying the live scenario into the original `.feature` is not; that goes through `workbench_request_save` and waits for Allow/Deny in the Swing banner. @@ -433,24 +438,28 @@ java -jar $workbenchJar ui ".\maven-consumer-project" Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-check` concurrently with the UI. -1. Verify the top-level layout has an expandable Features picker, the Live Gherkin Editor and compact Step Editor in the center, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. +1. Verify the top-level layout has a scenario name/tag filter rail (feature-file filter collapsed), the Live Gherkin Editor with a **Text | Blocks** toggle and compact Step Editor in the center, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. 2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site when no picker scenario is selected. -3. Click different scenario blocks and verify the playhead highlight moves immediately to the clicked step. -4. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. -5. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. -6. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. -7. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. -8. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. -9. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. -10. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Top-level properties come from the worker ParsingMap and accept typed in-place edits. -11. Verify Terminal filters worker log files by level and continues as steps run, without writing to MCP stdout. -12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. -13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. -14. Verify blocking runtime actions leave the Swing UI responsive. -15. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. -16. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/mapping/save/worker controls. **Take control** remains enabled. -17. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. -18. The default demo remains unsavable for both human Save and agent `workbench_request_save`. +3. Filter scenarios by name using contains (default) and the other match modes; confirm matching is case-insensitive and applies to the Scenario / Scenario Outline title. +4. Filter with include tags (AND) and exclude tags (NOT), with and without `@`, and confirm Feature-level tags apply to scenarios in that feature. +5. Confirm **Filter by feature** is collapsed by default and that name/tag filters then apply to every catalog scenario. Opening it still supports multi-select and Feature name vs file path. +6. Click a filtered scenario and verify it loads into the live buffer. Switch **Text | Blocks** and verify playhead, selection, and document text are unchanged. If WebView is unavailable, Blocks is disabled and Text remains the editor. +7. Click different scenario blocks/lines and verify the playhead highlight moves immediately to the clicked step. +8. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. +9. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. +10. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. +11. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. +12. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. +13. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. +14. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Top-level properties come from the worker ParsingMap and accept typed in-place edits. +15. Verify Terminal filters worker log files by level and continues as steps run, without writing to MCP stdout. +16. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. +17. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +18. Verify blocking runtime actions leave the Swing UI responsive. +19. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. +20. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/picker/filter/editor-view/mapping/save/worker controls. **Take control** remains enabled. +21. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. +22. The default demo remains unsavable for both human Save and agent `workbench_request_save`. ## Regression From 1c3de672d04311024a27c930f8286c309e63a4d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 19:14:48 +0000 Subject: [PATCH 11/15] Rename LiveEditorView availability query so it does not clash with the factory. Java cannot declare a static factory and an instance getter with the same name and arguments. Co-authored-by: ds-code-t --- .../java/tools/dscode/workbench/player/LiveEditorView.java | 2 +- .../main/java/tools/dscode/workbench/ui/WorkbenchFrame.java | 6 +++--- .../tools/dscode/workbench/player/LiveEditorViewTest.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java index bb959736..d0b0f980 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java @@ -30,7 +30,7 @@ public static LiveEditorView blocksUnavailable() { return new LiveEditorView(false); } - public boolean blocksAvailable() { + public boolean canShowBlocks() { return blocksAvailable; } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index 0f3bcf90..234d4d21 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -371,7 +371,7 @@ private JPanel editorViewToggle() { textViewButton.setFocusable(false); blocksViewButton.setFocusable(false); textViewButton.setToolTipText("View and edit the live scenario as ordinary Gherkin text."); - blocksViewButton.setToolTipText(editorView.blocksAvailable() + blocksViewButton.setToolTipText(editorView.canShowBlocks() ? "View and edit the same live buffer as Gherkin blocks." : "Block view requires JavaFX WebView, which is not available in this process."); textViewButton.addActionListener(event -> { @@ -405,7 +405,7 @@ private void applyEditorView() { cards.show(editorHost, blocks ? "web" : "text"); textViewButton.setSelected(!blocks); blocksViewButton.setSelected(blocks); - blocksViewButton.setEnabled(editorView.blocksAvailable() && !humanControlsLocked()); + blocksViewButton.setEnabled(editorView.canShowBlocks() && !humanControlsLocked()); textViewButton.setEnabled(!humanControlsLocked()); if (blocks) { pushGherkinView(); @@ -1499,7 +1499,7 @@ private void applyLease(WorkbenchControlLeaseSnapshot snapshot) { playerStopButton.setEnabled(!locked); stepOnlyButton.setEnabled(!locked); textViewButton.setEnabled(!locked); - blocksViewButton.setEnabled(editorView.blocksAvailable() && !locked); + blocksViewButton.setEnabled(editorView.canShowBlocks() && !locked); takeControlButton.setEnabled(locked); if (locked) { fromHereButton.setEnabled(false); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java index 02dce57c..f2f2bbc2 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java @@ -40,7 +40,7 @@ void togglingTextAndBlocksDoesNotChangeDocumentTextOrPlayhead() { @Test void unavailableBlocksStayOnTextAndRefuseBlockMode() { LiveEditorView view = LiveEditorView.blocksUnavailable(); - assertFalse(view.blocksAvailable()); + assertFalse(view.canShowBlocks()); assertEquals(LiveEditorView.Mode.TEXT, view.mode()); assertFalse(view.showBlocks()); assertEquals(LiveEditorView.Mode.TEXT, view.mode()); From f3948e1bdf849297c5cec1d92b58c8c047e4cf6e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 00:40:17 +0000 Subject: [PATCH 12/15] Add consumer-side Pickleball guidance pointers for Copilot and README. Keep the nested AGENTS.md exporter one-liner, add the same bootstrap for IntelliJ Copilot Chat, and point the consumer README at AGENTS.md without copying the full guide or snapshotting those bridges. Co-authored-by: ds-code-t --- docs/agent/README.md | 4 +- docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 1 + docs/consumer-project.md | 2 +- .../.github/copilot-instructions.md | 1 + maven-consumer-project/README.md | 2 + .../diagnostic/PickleballGuidanceChecks.java | 1 + scripts/refresh_agent_index.py | 1 + scripts/verify_agent_contract.py | 84 ++++++++++++------- .../pickleball/guidance/docs/agent/README.md | 4 +- .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 1 + .../guidance/docs/consumer-project.md | 2 +- 13 files changed, 69 insertions(+), 38 deletions(-) create mode 100644 maven-consumer-project/.github/copilot-instructions.md diff --git a/docs/agent/README.md b/docs/agent/README.md index b3324a47..efb17fb1 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -20,7 +20,9 @@ Agent adapters should remain small and point back to the canonical contract rath The nested `/maven-consumer-project/AGENTS.md` is intentionally only a dependency-owned guidance bootstrap. It materializes version-matched guidance and directs the consumer agent to `.pickleball/AGENT-GUIDE.md`. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. -The nested `/maven-consumer-project/README.md` is ordinary sample-project documentation and should not duplicate the AI guidance lifecycle. +The nested `/maven-consumer-project/.github/copilot-instructions.md` is the same one-line bootstrap for IntelliJ Copilot Chat, which reads that file rather than `AGENTS.md`. + +The nested `/maven-consumer-project/README.md` is ordinary sample-project documentation. It may point humans and agents at `AGENTS.md` for guidance export, but should not duplicate the AI guidance lifecycle. `export-guidance .pickleball` is deliberately unconditional before Pickleball work. A successful export writes `.pickleball/GUIDANCE-MANIFEST.json` last, removes obsolete previously managed files, and refreshes current dependency guidance. Git-ignore handling is best effort. If export fails, existing `.pickleball` content is potentially stale. diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 3a2226af..8fcff1d1 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -19,7 +19,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/config-files-and-resource-mapping.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements` | `mapping-and-resources.feature`; `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; Data Element features; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md` | | Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_run_profile`, `pkb_configpath` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | -| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; search `export-guidance`, `GUIDANCE-MANIFEST.json` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | +| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `GUIDANCE-MANIFEST.json` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; diagnostic aspects; `DiagnosticCli.java`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | | Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation | `nested-and-block-conditionals.feature` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 6d0e3fea..163dbf16 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -9,6 +9,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `build.gradle` - `gradlew` - `gradlew.bat` +- `maven-consumer-project/.github/copilot-instructions.md` - `maven-consumer-project/.mvn/wrapper/maven-wrapper.properties` - `maven-consumer-project/AGENTS.md` - `maven-consumer-project/mvnw` diff --git a/docs/consumer-project.md b/docs/consumer-project.md index c4954679..4633e07b 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -42,7 +42,7 @@ The snapshot includes: - static local test-site resources; and - the committed shared/local `profiles*.yaml` and `pickleball*.properties` examples. -It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` bridge, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to edit or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. +It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` and `.github/copilot-instructions.md` bridges, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to edit or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. ## Purpose diff --git a/maven-consumer-project/.github/copilot-instructions.md b/maven-consumer-project/.github/copilot-instructions.md new file mode 100644 index 00000000..e303a3a3 --- /dev/null +++ b/maven-consumer-project/.github/copilot-instructions.md @@ -0,0 +1 @@ +Before any Pickleball work, run `mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.common.reporting.diagnostic.DiagnosticCli" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. diff --git a/maven-consumer-project/README.md b/maven-consumer-project/README.md index 1046cff6..f0fd1bfb 100644 --- a/maven-consumer-project/README.md +++ b/maven-consumer-project/README.md @@ -2,6 +2,8 @@ This is a standalone Maven example project that consumes Pickleball as a test-scoped dependency and exercises browser and service behavior against its local test site. +Before Pickleball work, export guidance and see `AGENTS.md`. + ## Run ```bash diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java index c0bc3c08..be4a697d 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java @@ -112,6 +112,7 @@ void dependencyExportsVersionMatchedGuidanceAndManifest() throws Exception { )); assertTrue(managedFiles.stream().noneMatch(path -> path.contains("_local2"))); assertFalse(managedFiles.contains("maven-consumer-project/AGENTS.md")); + assertFalse(managedFiles.contains("maven-consumer-project/.github/copilot-instructions.md")); assertFalse(managedFiles.contains("maven-consumer-project/mvnw")); assertFalse(managedFiles.contains( "maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java" diff --git a/scripts/refresh_agent_index.py b/scripts/refresh_agent_index.py index d498019f..b70dc42a 100644 --- a/scripts/refresh_agent_index.py +++ b/scripts/refresh_agent_index.py @@ -31,6 +31,7 @@ "gradlew", "gradlew.bat", "maven-consumer-project/AGENTS.md", + "maven-consumer-project/.github/copilot-instructions.md", "maven-consumer-project/pom.xml", "maven-consumer-project/README.md", "maven-consumer-project/mvnw", diff --git a/scripts/verify_agent_contract.py b/scripts/verify_agent_contract.py index 7ce81450..4ef92196 100644 --- a/scripts/verify_agent_contract.py +++ b/scripts/verify_agent_contract.py @@ -47,6 +47,7 @@ "scripts/agent_validate.sh", "scripts/agent_validate.ps1", "maven-consumer-project/AGENTS.md", + "maven-consumer-project/.github/copilot-instructions.md", "maven-consumer-project/README.md", "maven-consumer-project/.gitignore", "maven-consumer-project/mvnw", @@ -79,7 +80,6 @@ "maven-consumer-project/GEMINI.md", "maven-consumer-project/REVIEW.md", "maven-consumer-project/.clinerules", - "maven-consumer-project/.github/copilot-instructions.md", } ADAPTER_FILES = ( "CLAUDE.md", @@ -136,6 +136,7 @@ "pickleball-workbench/AGENTS.md", "maven-consumer-project/README.md", "maven-consumer-project/AGENTS.md", + "maven-consumer-project/.github/copilot-instructions.md", } WORKBENCH_CONTRACT_FILES = ( @@ -278,43 +279,57 @@ def starts_with_any(path: str, prefixes: tuple[str, ...]) -> bool: return any(path.startswith(prefix) for prefix in prefixes) -def validate_consumer_bridge(errors: list[str]) -> None: - bridge = ROOT / "maven-consumer-project" / "AGENTS.md" - if not bridge.is_file(): - return +CONSUMER_BRIDGE_FILES = ( + "maven-consumer-project/AGENTS.md", + "maven-consumer-project/.github/copilot-instructions.md", +) - text = bridge.read_text(encoding="utf-8").strip() - nonblank_lines = [line for line in text.splitlines() if line.strip()] - if len(nonblank_lines) != 1: - errors.append( - "Consumer AGENTS bridge must stay a single nonblank bootstrap line: " - "maven-consumer-project/AGENTS.md" - ) - for required in ( - "DiagnosticCli", - "export-guidance", - ".pickleball/AGENT-GUIDE.md", - ): - if required not in text: - errors.append( - f"Consumer AGENTS bridge must reference {required}: " - "maven-consumer-project/AGENTS.md" - ) +def validate_consumer_bridge(errors: list[str]) -> None: + texts: list[str] = [] + for relative in CONSUMER_BRIDGE_FILES: + path = ROOT / relative + if not path.is_file(): + continue - for forbidden in ( - "GUIDANCE-MANIFEST.json", - ".git/info/exclude", - "pkb_changed_variables", - "runProfileFingerprint", - "Diagnostic investigation protocol", - ): - if forbidden in text: + text = path.read_text(encoding="utf-8").strip() + texts.append(text) + nonblank_lines = [line for line in text.splitlines() if line.strip()] + if len(nonblank_lines) != 1: errors.append( - f"Consumer AGENTS bridge contains dependency-owned guidance ({forbidden}); " - "keep only the bootstrap command and generated-guide pointer." + "Consumer guidance bridge must stay a single nonblank bootstrap line: " + + relative ) + for required in ( + "DiagnosticCli", + "export-guidance", + ".pickleball/AGENT-GUIDE.md", + ): + if required not in text: + errors.append( + f"Consumer guidance bridge must reference {required}: {relative}" + ) + + for forbidden in ( + "GUIDANCE-MANIFEST.json", + ".git/info/exclude", + "pkb_changed_variables", + "runProfileFingerprint", + "Diagnostic investigation protocol", + ): + if forbidden in text: + errors.append( + f"Consumer guidance bridge contains dependency-owned guidance ({forbidden}); " + "keep only the bootstrap command and generated-guide pointer: " + + relative + ) + + if len(texts) == len(CONSUMER_BRIDGE_FILES) and len(set(texts)) != 1: + errors.append( + "Consumer AGENTS.md and .github/copilot-instructions.md bridges must be identical." + ) + def validate_consumer_readme(errors: list[str]) -> None: readme = ROOT / "maven-consumer-project" / "README.md" @@ -322,6 +337,11 @@ def validate_consumer_readme(errors: list[str]) -> None: return text = readme.read_text(encoding="utf-8") + if "AGENTS.md" not in text: + errors.append( + "Consumer README must point to AGENTS.md for guidance export: " + "maven-consumer-project/README.md" + ) for forbidden in ( "export-guidance", ".pickleball/", diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md index b3324a47..efb17fb1 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md @@ -20,7 +20,9 @@ Agent adapters should remain small and point back to the canonical contract rath The nested `/maven-consumer-project/AGENTS.md` is intentionally only a dependency-owned guidance bootstrap. It materializes version-matched guidance and directs the consumer agent to `.pickleball/AGENT-GUIDE.md`. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. -The nested `/maven-consumer-project/README.md` is ordinary sample-project documentation and should not duplicate the AI guidance lifecycle. +The nested `/maven-consumer-project/.github/copilot-instructions.md` is the same one-line bootstrap for IntelliJ Copilot Chat, which reads that file rather than `AGENTS.md`. + +The nested `/maven-consumer-project/README.md` is ordinary sample-project documentation. It may point humans and agents at `AGENTS.md` for guidance export, but should not duplicate the AI guidance lifecycle. `export-guidance .pickleball` is deliberately unconditional before Pickleball work. A successful export writes `.pickleball/GUIDANCE-MANIFEST.json` last, removes obsolete previously managed files, and refreshes current dependency guidance. Git-ignore handling is best effort. If export fails, existing `.pickleball` content is potentially stale. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index 3a2226af..8fcff1d1 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -19,7 +19,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/config-files-and-resource-mapping.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements` | `mapping-and-resources.feature`; `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; Data Element features; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md` | | Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_run_profile`, `pkb_configpath` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | -| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; search `export-guidance`, `GUIDANCE-MANIFEST.json` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | +| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `GUIDANCE-MANIFEST.json` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; diagnostic aspects; `DiagnosticCli.java`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | | Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation | `nested-and-block-conditionals.feature` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 6d0e3fea..163dbf16 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -9,6 +9,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `build.gradle` - `gradlew` - `gradlew.bat` +- `maven-consumer-project/.github/copilot-instructions.md` - `maven-consumer-project/.mvn/wrapper/maven-wrapper.properties` - `maven-consumer-project/AGENTS.md` - `maven-consumer-project/mvnw` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index c4954679..4633e07b 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -42,7 +42,7 @@ The snapshot includes: - static local test-site resources; and - the committed shared/local `profiles*.yaml` and `pickleball*.properties` examples. -It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` bridge, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to edit or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. +It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` and `.github/copilot-instructions.md` bridges, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to edit or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. ## Purpose From e8f965fcb86d01713b708f44cdabe98411ccc5f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 04:05:03 +0000 Subject: [PATCH 13/15] Teach consumer agents the live MCP loop and skip unchanged Workbench compiles. Put a headless MCP tool chooser first in AGENT-GUIDE, skip Maven/Gradle when Java did not change, refresh resources without test-compile, and add sparse diagnostic catalog/run/summary MCP tools. Co-authored-by: ds-code-t --- docs/README.md | 2 +- docs/agent/feature-map.md | 6 +- docs/agent/repository-index.md | 4 + docs/ai-run-configuration.md | 6 + docs/consumer-agent-guide.md | 87 +++++-- docs/consumer-project.md | 22 +- docs/pickleball-workbench.md | 51 ++-- .../diagnostic/PickleballGuidanceChecks.java | 17 +- pickleball-workbench/AGENTS.md | 6 +- .../dscode/workbench/WorkbenchController.java | 18 ++ .../dscode/workbench/WorkbenchServices.java | 6 + .../DiagnosticEvidenceNavigator.java | 60 +++++ .../workbench/mcp/WorkbenchMcpTools.java | 16 ++ .../workbench/sync/WorkbenchManifest.java | 66 ++++- .../workbench/sync/WorkbenchSyncInputs.java | 239 ++++++++++++++++++ .../workbench/sync/WorkbenchSyncMode.java | 11 + .../workbench/sync/WorkbenchSyncPlanner.java | 34 +++ .../workbench/sync/WorkbenchSynchronizer.java | 140 +++++++++- .../catalog/ConsumerFeatureCatalogTest.java | 8 +- .../DiagnosticEvidenceNavigatorTest.java | 39 +++ .../workbench/mcp/WorkbenchMcpServerTest.java | 41 +++ .../sync/WorkbenchSyncPlannerTest.java | 90 +++++++ .../sync/WorkbenchSynchronizerTest.java | 198 +++++++++++++++ .../ui/WorkbenchUiControllerTest.java | 8 +- .../worker/WorkbenchWorkerManagerTest.java | 8 +- .../pickleball/guidance/AGENT-GUIDE.md | 87 +++++-- .../pickleball/guidance/docs/README.md | 2 +- .../guidance/docs/agent/feature-map.md | 6 +- .../guidance/docs/agent/repository-index.md | 4 + .../guidance/docs/ai-run-configuration.md | 6 + .../guidance/docs/consumer-agent-guide.md | 87 +++++-- .../guidance/docs/consumer-project.md | 22 +- .../guidance/docs/pickleball-workbench.md | 51 ++-- 33 files changed, 1307 insertions(+), 141 deletions(-) create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncMode.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java diff --git a/docs/README.md b/docs/README.md index 6f1ae4cd..3d8d3cb7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ Pickleball extends Cucumber with a dynamic feature-file language while preserving normal Cucumber behavior. The pages below describe the supported authoring model and link to real executable examples in [`maven-consumer-project`](consumer-project.md). -When these docs are materialized from the Maven dependency with `DiagnosticCli export-guidance`, links to `../maven-consumer-project/...` resolve to the version-matched, read-only reference snapshot exported beside the docs. Human readers and AI agents can therefore inspect the same working features, configuration, calls, data, runner, and local test-site examples without checking out the Pickleball source repository. +When these docs are materialized from the Maven dependency with `DiagnosticCli export-guidance`, links to `../maven-consumer-project/...` resolve to the version-matched, read-only reference snapshot exported beside the docs. Human readers can inspect those working features, configuration, calls, data, runner, and local test-site examples without checking out the Pickleball source repository. Consumer AI agents should follow `.pickleball/AGENT-GUIDE.md` first and open a specific guide only when needed. ## Start here diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 8fcff1d1..e4121374 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -41,12 +41,12 @@ The standalone Workbench shadow JAR is controller-only and self-contained, with The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `PKB_CONTROL_BRIDGE_SESSION_ID`, `PKB_CONTROL_BRIDGE_TOKEN`, and `PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO`. Pickleball may also accept the prior `PKB_STUDIO_BRIDGE_*` names as deprecated compatibility input aliases. New Workbench code must use only the neutral names. -`WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. +`WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. Input fingerprints of Java sources, resources, build files, and dependency artifacts decide skip vs resources-only vs full compile; the output fingerprint in `manifest.json` remains provenance, not a skip key. Sync always passes `-DskipTests`. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. Live Gherkin buffer edits do not require sync. `WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through the protocol-owned worker class-name contract, without linking the worker class or invoking Maven/Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is the Text vs Blocks presentation choice for that same buffer; toggling it must not change document text, selection, or playhead id. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is Gherkin text or a block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is the Text vs Blocks presentation choice for that same buffer; toggling it must not change document text, selection, or playhead id. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use headless MCP (`mcp .`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` so a watcher can join a human GUI session instead of starting a second Workbench; that attach file is not the consumer-agent path for this release. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is Gherkin text or a block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 163dbf16..f44d9c18 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -505,6 +505,9 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncMode.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java` @@ -550,6 +553,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index 97ef5401..109a9fda 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -243,6 +243,12 @@ Example compact rerun: -Dpkb_changed_variables=pkb_browser ``` +For an agent's bounded confirmation `mvn test` (not `PickleballTests` human defaults of `pretty` / `@all`), include diagnostic evidence controls and keep selection narrow: + +```text +-Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + Lineage metadata is not execution configuration: ```text diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 9160a940..ac261637 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -2,7 +2,53 @@ This is the canonical AI-agent contract for projects that consume Pickleball as a Maven dependency. -A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. +A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. Full `docs/` and the snapshot stay exported for on-demand lookup. Do not dump them into first-read context. + +## Tool chooser + +Use this order. Consumer AI agents for this Pickleball release use headless Workbench MCP (`mcp .`). Do not start the Workbench GUI. + +1. **Live headless MCP** — isolate a failing step in a paused worker. Reuse compilation, rewrite Gherkin in the live buffer, and inspect the page and semantic events in the same browser/Mapping state. +2. **One diagnostic `mvn test`** — after the live loop has isolated the failure, run one bounded confirmation with `pkb_runvars` so an evidence pack is retained. +3. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. + +Do not copy consumer features into `.pickleball` as a sandbox. + +### Live isolation loop + +From the consumer project, with Pickleball on the test classpath: + +1. Start the launcher with `mcp .` (not a GUI command). +2. Call `workbench_sync` once. The synchronizer skips Maven/Gradle when Java/build/dependencies are unchanged, and refreshes test resources without a full `test-compile` when only features/config/data changed. +3. `workbench_worker_start` — reuse the compiled live classpath; do not rebuild to start a worker. +4. `workbench_request_control` +5. Isolate with `workbench_execute_step` and/or `workbench_player_replace_document`. +6. Inspect with `workbench_browser_page`, `workbench_element_inspect`, and `workbench_events`. +7. When you need a retained evidence pack, run **one** diagnostic `mvn test` with `pkb_runvars` (below). Read that pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` instead of globbing `reports/diagnostic-runs`. + +`workbench_execute_step` failure does not end the worker. Insert, nest, or retry in the same paused browser/Mapping state. Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). + +Worker restart without rebuild already exists (`workbench_worker_restart`). Step Overrides compile worker-side (`workbench_step_override_compile`); they do not require Maven. + +### Generated trees are not the project + +- `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. +- `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. +- `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. + +## First-read + +Keep first-read small. After a successful export: + +1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. +2. Stay in this guide's tool chooser and live loop. +3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. +4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. +5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. + +Do not read `docs/README.md`, the whole `maven-consumer-project/` snapshot, or Workbench GUI pages as first actions. Those remain available on demand. + +The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. ## Generated guidance lifecycle @@ -19,26 +65,13 @@ The exporter does not create/commit a new `.gitignore`, alter the Git index, or Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Those leftovers are not authoritative for the downgraded dependency. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. -## First actions - -For Pickleball scenario authoring, configuration, execution, diagnostics, or troubleshooting: - -1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Read this guide before changing Pickleball scenarios or diagnosing a Pickleball run. -3. Use `docs/README.md` as the documentation map. -4. Inspect the consumer project's `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. -5. Use `maven-consumer-project/` as a version-matched read-only reference when a documented syntax/configuration example or working Pickleball consumer structure is useful. -6. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. - -The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. - ## Generated Maven consumer reference -`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. +`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. It is not the consumer project under test and is not a writable sandbox. The snapshot intentionally includes the consumer `pom.xml`, Pickleball runner, local browser/service test server, executable feature files, service-call definitions, configuration/data fixtures, local test-site resources, and the committed shared/local profile and property examples. It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` bridge, internal Java verification classes, and maintainer-only `_local2` files. -Use the snapshot to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not modify or execute files under `.pickleball/maven-consumer-project/` as the consumer project's implementation. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. +Use the snapshot only to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not copy, modify, or execute files under `.pickleball/maven-consumer-project/` as the project under test. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. `export-guidance` does not copy the consumer's own features into `.pickleball` for testing. ## Scenario authoring and fixes @@ -100,6 +133,22 @@ Never supply `pkb_run_profile` or `pkb_run_profile.` as input. They are When you launch Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative input. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. +For an agent's bounded confirmation `mvn test` (not the human runner defaults), include diagnostic evidence controls and keep the selection narrow. Documented AI defaults: + +```text +pkb_reportingmode=diagnostic +pkb_loglevel=warn +pkb_reportretention=failed +``` + +Use the narrowest `pkb_tags` / `pkb_name` that isolate the failure. Do not add the `pretty` plugin; it is console noise for agents. `pkb_reportretention=failed` keeps dense evidence for failing scenarios and does not retain it for passing ones. + +These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`). Example confirmation after a live-loop isolation: + +```text +mvn test -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + A selected profile or partial `pkb_runvars` input inherits only missing project execution-context RunVars: ```text @@ -139,6 +188,8 @@ Use this escalation order: Stop reading as soon as the current layer answers the investigation. Do not recursively ingest an entire diagnostic run. +From headless Workbench MCP, use `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` for layers 1–3 instead of globbing `reports/diagnostic-runs`. Those tools return sparse JSON only and do not dump `events.jsonl`, traces, or screenshot bytes. + ## Visual evidence rules - Never open a PNG merely to determine whether two screenshots differ. @@ -198,7 +249,7 @@ See `docs/ai-run-configuration.md` for the full profile/RunVar contract and `doc ## Pickleball syntax documentation -The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Use `docs/README.md` to select the relevant guide. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: +The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Open a specific guide when the live loop or a diagnostic layer requires that topic; do not start by reading `docs/README.md` as a dump. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: - dynamic Gherkin/action/assertion syntax — `docs/dynamic-steps.md`; - element vocabulary/selectors — `docs/custom-element-definitions.md`; @@ -217,7 +268,7 @@ Do not guess Pickleball syntax when the version-matched guide or executable cons ## Human-readable consumer guidance -Use `docs/consumer-project.md` for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Use `docs/README.md` to navigate the complete bundled documentation. Human readers can open files under `maven-consumer-project/` directly in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples linked from those guides. +Use `docs/consumer-project.md` on demand for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Human readers can start with `docs/README.md` and open files under `maven-consumer-project/` in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples. Agents should not treat those as first-read. ## When the core Pickleball repository is also present diff --git a/docs/consumer-project.md b/docs/consumer-project.md index 4633e07b..ace01d46 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -26,11 +26,11 @@ Rerun export before Pickleball work even when `.pickleball` already exists. A su Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Those leftovers are not authoritative for the downgraded dependency; prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Human readers can start with `.pickleball/docs/README.md`; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. That guide's tool chooser is the agent path: headless Workbench MCP (`mcp .`), one bounded diagnostic `mvn test`, then edits to the real consumer source. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md`; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot -`export-guidance` also materializes a curated, read-only snapshot of the canonical Pickleball Maven consumer under `.pickleball/maven-consumer-project/`. It is intended for both human readers and AI agents that need concrete working examples in addition to prose documentation. +`export-guidance` also materializes a curated, read-only snapshot of the canonical Pickleball Maven consumer under `.pickleball/maven-consumer-project/`. It is a version-matched **reference** of Pickleball's own example consumer for on-demand lookup, not a sandbox and not the consumer project under test. `export-guidance` does not copy the current consumer's own features into `.pickleball` for testing. The snapshot includes: @@ -42,7 +42,7 @@ The snapshot includes: - static local test-site resources; and - the committed shared/local `profiles*.yaml` and `pickleball*.properties` examples. -It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` and `.github/copilot-instructions.md` bridges, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to edit or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. +It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` and `.github/copilot-instructions.md` bridges, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to copy, edit, or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. `.pickleball/workbench/live/classes` is a compiled worker overlay, not an editor. ## Purpose @@ -77,20 +77,20 @@ or use the included wrappers: ## Launch the dependency-matched Workbench -The test-scoped Pickleball dependency already contains its controller-only Workbench payload. Start the launcher from the resolved test classpath without finding a Maven cache entry or declaring a second version: +The test-scoped Pickleball dependency already contains its controller-only Workbench payload. Consumer AI agents start the **headless MCP** launcher from the resolved test classpath: ```bash ./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ - "-Dexec.args=ui ." + "-Dexec.args=mcp ." ``` ```powershell -.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -The launcher verifies and extracts the opaque payload beneath `.pickleball/workbench/controller//`, then creates a separate Workbench JVM. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. UI and MCP both use the same controller/live session. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. +Humans who want the Swing player can pass `ui .` instead. Agents for this release should not use the GUI, `ui .`, or `attach.json` as their path. The launcher verifies and extracts the opaque payload beneath `.pickleball/workbench/controller//`, then creates a separate Workbench JVM. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. Runner defaults include: @@ -152,6 +152,12 @@ mvn test -Dpkb_tags="@forms and @state-assertions" mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" ``` +Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a bounded confirmation should not reuse those defaults. Use a separate `pkb_runvars` command, for example: + +```bash +mvn test -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + The consumer `pom.xml` also defines Maven profiles such as: ```bash @@ -260,4 +266,4 @@ When evidence supports a bounded rerun: - Port `8765` must be available for the example test server. - Nested README/AGENTS files are minimal adapters; detailed guidance and version-matched reference examples are owned by Pickleball core and exported from the dependency. -Use `docs/README.md` for the complete version-matched Pickleball syntax/documentation map and `maven-consumer-project/` for the corresponding working reference files. +Human readers can use `docs/README.md` for the complete version-matched Pickleball syntax/documentation map and `maven-consumer-project/` for the corresponding working reference files. Agents should not treat those as first-read. diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 8df90b18..950c8f65 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -43,20 +43,22 @@ The normal `tools.dscode:pickleball:` test dependency already carries t META-INF/pickleball/workbench/pickleball-workbench.jar ``` -Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version: +Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version. + +Consumer AI agents use headless MCP: ```bash mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ - "-Dexec.args=ui ." + "-Dexec.args=mcp ." ``` ```powershell -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -With no launcher arguments, `ui` and the current directory are selected automatically. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."` or `"-Dexec.args=mcp ."`. +Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Agents for this release should not use the GUI, `ui .`, or `.pickleball/workbench/attach.json` as their path; see `.pickleball/AGENT-GUIDE.md`. Gradle consumers can expose the same dependency-owned launcher without resolving a cache path or adding a Workbench dependency: @@ -64,7 +66,7 @@ Gradle consumers can expose the same dependency-owned launcher without resolving tasks.register('pickleballWorkbench', JavaExec) { classpath = sourceSets.test.runtimeClasspath mainClass = 'tools.dscode.launcher.PickleballWorkbenchLauncher' - args 'ui', projectDir.absolutePath + args 'mcp', projectDir.absolutePath // consumer agents; pass 'ui' for the Swing player } ``` @@ -99,7 +101,15 @@ $workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. - -From `maven-consumer-project`: - -```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ - -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ - -Dexec.classpathScope=test \ - "-Dexec.args=ui ." -``` +Headless `java -jar pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That is the consumer-agent path. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. -Then point the agent at `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session. +A human-watched UI session is optional and separate. From `maven-consumer-project`, a person may start `ui .` and then a watcher can join `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session, and do not treat that attach file as the default agent path. ## MCP stdio -Start the lightweight non-Spring MCP server for a synchronized consumer project: +Start the lightweight non-Spring MCP server for a consumer project. This is the consumer-agent path: ```powershell java -jar $workbenchJar mcp ".\maven-consumer-project" ``` +Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."`. Do not document or use the Swing GUI as the agent path. + The server uses the official Java MCP SDK core and stdio transport with the Jackson 2 JSON adapter. MCP dependencies are Workbench-only and are shaded into the executable companion. Workbench deliberately does not use Spring Boot, Spring Framework, Spring AI, WebMVC, or Tomcat. ### Stdout contract @@ -345,6 +350,8 @@ workbench_worker_stop workbench_worker_status ``` +`workbench_sync` uses the skip / resources-only / full rules above. Live buffer edits do not require it. + Live runtime, Mapping, and watched-agent control: ```text @@ -391,6 +398,14 @@ workbench_step_override_remove workbench_step_override_clear ``` +Sparse diagnostic readers (do not glob `reports/diagnostic-runs`; these return JSON only and do not dump events, traces, or PNG bytes): + +```text +workbench_diagnostic_catalog +workbench_diagnostic_run +workbench_diagnostic_summary +``` + `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java index be4a697d..956968a3 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java @@ -40,8 +40,17 @@ void dependencyPrintsCanonicalAgentGuide() { assertTrue(guide.contains("GUIDANCE-MANIFEST.json")); assertTrue(guide.contains("keep terminal logging minimal")); assertTrue(guide.contains("older Pickleball release whose exporter predates the manifest lifecycle")); - assertTrue(guide.contains("Generated Maven consumer reference")); - assertTrue(guide.contains("maven-consumer-project/")); + assertTrue(guide.contains("Generated Maven consumer reference")); + assertTrue(guide.contains("maven-consumer-project/")); + assertTrue(guide.contains("mcp .")); + assertTrue(guide.contains("workbench_sync")); + assertTrue(guide.contains("workbench_execute_step")); + assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("pkb_reportingmode=diagnostic")); + assertTrue(guide.contains("pkb_reportretention=failed")); + String chooser = guide.substring(0, guide.indexOf("Generated guidance lifecycle")); + assertFalse(chooser.contains("attach.json")); + assertFalse(chooser.contains("ui .")); } @Test @@ -125,6 +134,10 @@ void dependencyExportsVersionMatchedGuidanceAndManifest() throws Exception { assertTrue(guide.contains("keep terminal logging minimal")); assertTrue(guide.contains("older Pickleball release whose exporter predates the manifest lifecycle")); assertTrue(guide.contains("read-only reference snapshot")); + assertTrue(guide.contains("mcp .")); + assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("pkb_reportretention=failed")); + assertTrue(guide.contains("Do not copy, modify, or execute files")); String consumerProject = Files.readString(root.resolve("docs/consumer-project.md")); assertTrue(consumerProject.contains("keep console verbosity low")); diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 201ad4bf..718c5e0a 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -103,7 +103,7 @@ Workbench provides: java -jar pickleball-workbench-.jar mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, and gated Save through `WorkbenchServices`. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, and sparse diagnostic catalog/run/summary readers through `WorkbenchServices`. Consumer agents use this headless stdio server (`mcp .`), not the Swing GUI. UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. @@ -115,9 +115,9 @@ Do not add generic IDE/file/build/process/collaboration tools to this MCP surfac ## Synchronization and worker lifecycle -Workbench synchronization is build-tool-assisted, not a replacement build system. Use the selected Maven/Gradle wrapper to establish compiled main/test output, processed resources, and the effective test runtime classpath. Gradle synchronization must use build-native init-script/task injection rather than the Gradle Tooling API. +Workbench synchronization is build-tool-assisted, not a replacement build system. Use the selected Maven/Gradle wrapper to establish compiled main/test output, processed resources, and the effective test runtime classpath. Gradle synchronization must use build-native init-script/task injection rather than the Gradle Tooling API. Compare **input** fingerprints (Java sources, resources, build files, dependency artifact bytes) to the last manifest before invoking the wrapper: skip when nothing that requires recompilation changed; run resource processing only when only feature/config/data changed; run full `test-compile` / `testClasses` when Java, the build descriptor, or dependencies changed. The output fingerprint in `manifest.json` is provenance, not the skip key. Always pass `-DskipTests`. Live Gherkin buffer edits must never require sync. If compiled project outputs are missing after a clean, escalate resources-only to full compile. -`.pickleball/workbench/base/classes` is synchronization provenance/reset state and must never be on a worker runtime classpath. `.pickleball/workbench/live/classes` is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. +`.pickleball/workbench/base/classes` is synchronization provenance/reset state and must never be on a worker runtime classpath. `.pickleball/workbench/live/classes` is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. Do not treat `live/classes` as an editor. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. The controller owns one interactive worker per selected project by default. Workers launch directly with Java from the existing Workbench snapshot, use the Pickleball-side worker class-name contract, and set the protocol-owned `pickleball.workbench.testOutputRoot` property so core intentionally scans the merged live root instead of relying on Maven/Gradle output suffixes. The worker PID must differ from the controller PID; its reported Pickleball code source must be exactly one captured consumer classpath entry; its version must match the synchronized manifest; and its classpath must exclude the Workbench controller artifact. Incompatible protocol/capability/origin checks fail clearly and never fall back to a bundled runtime. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index b590ae02..a43d93c5 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -16,6 +16,7 @@ import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; +import tools.dscode.workbench.diagnostics.DiagnosticEvidenceNavigator; import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchLiveSession; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -35,6 +36,7 @@ public final class WorkbenchController implements WorkbenchServices { private final LiveScenarioPlayer player; private final LivePlaybackCoordinator playback; private final WorkbenchControlLease lease; + private final DiagnosticEvidenceNavigator diagnostics; private final List playerListeners = new CopyOnWriteArrayList<>(); public WorkbenchController(Path projectRoot) { @@ -44,6 +46,7 @@ public WorkbenchController(Path projectRoot) { this.player = LiveScenarioPlayer.interactiveBuffer(); this.playback = new LivePlaybackCoordinator(this.player); this.lease = new WorkbenchControlLease(); + this.diagnostics = new DiagnosticEvidenceNavigator(this.projectRoot); } @Override @@ -358,6 +361,21 @@ public int clearStepOverrides() { return mutating(live::clearStepOverrides); } + @Override + public Object diagnosticCatalog() { + return diagnostics.catalogDocument(); + } + + @Override + public Object diagnosticRun(String runId) { + return diagnostics.runDocument(runId); + } + + @Override + public Object diagnosticScenarioSummary(String runId, String scenarioId) { + return diagnostics.scenarioSummaryDocument(runId, scenarioId); + } + @Override public void close() { lease.detachUi(); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java index a7b5ce0f..71b8e97b 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -143,6 +143,12 @@ ControlBridgeBreakpoint addBreakpoint( int clearStepOverrides(); + Object diagnosticCatalog(); + + Object diagnosticRun(String runId); + + Object diagnosticScenarioSummary(String runId, String scenarioId); + @Override void close(); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java index 7f582e66..daa1f6f0 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java @@ -99,6 +99,66 @@ public List catalogRuns() { return List.copyOf(runs); } + public JsonNode catalogDocument() { + Path catalog = diagnosticRoot.resolve("run-catalog.json"); + var result = JSON.createObjectNode(); + result.put("available", Files.isRegularFile(catalog)); + result.put("path", catalog.toString()); + if (Files.isRegularFile(catalog)) { + result.set("catalog", readJson(catalog)); + } + return result; + } + + public JsonNode runDocument(String runId) { + Path runRoot = resolveContained(diagnosticRoot, runId, "runId"); + Path index = runRoot.resolve("run-index.json"); + Path clusters = runRoot.resolve("clusters.json"); + var result = JSON.createObjectNode(); + result.put("runId", runId); + result.put("runRoot", runRoot.toString()); + result.put("indexPresent", Files.isRegularFile(index)); + result.put("clustersPresent", Files.isRegularFile(clusters)); + if (Files.isRegularFile(index)) { + result.set("runIndex", readJson(index)); + } + if (Files.isRegularFile(clusters)) { + result.set("clusters", readJson(clusters)); + } + return result; + } + + public JsonNode scenarioSummaryDocument(String runId, String scenarioId) { + Path runRoot = resolveContained(diagnosticRoot, runId, "runId"); + Path scenarioDir = resolveContained(runRoot.resolve("scenarios"), scenarioId, "scenarioId"); + Path summary = scenarioDir.resolve("summary.json"); + if (!Files.isRegularFile(summary)) { + throw new IllegalArgumentException("No summary.json for scenario " + scenarioId + " in run " + runId); + } + var result = JSON.createObjectNode(); + result.put("runId", runId); + result.put("scenarioId", scenarioId); + result.put("path", summary.toString()); + result.set("summary", readJson(summary)); + return result; + } + + private static Path resolveContained(Path root, String name, String label) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + if (name.equals(".") || name.equals("..") + || name.contains("/") || name.contains("\\") || name.contains("..")) { + throw new IllegalArgumentException(label + " must be a simple directory name."); + } + Path base = root.toAbsolutePath().normalize(); + Path resolved = base.resolve(name).normalize(); + if (!resolved.startsWith(base)) { + throw new IllegalArgumentException(label + " is outside the diagnostic store."); + } + return resolved; + } + public Timeline timeline(Path runRoot) { Path root = runRoot.toAbsolutePath().normalize(); JsonNode index = readJsonIfPresent(root.resolve("run-index.json")); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index f8515aee..22bf3bd0 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java @@ -193,6 +193,22 @@ private void register() { args -> Map.of("removed", services.removeStepOverride(text(args, "id")))); add("workbench_step_override_clear", "Clear all Step Overrides from the active scenario.", schema(Map.of()), args -> Map.of("removed", services.clearStepOverrides())); + + add("workbench_diagnostic_catalog", + "Read reports/diagnostic-runs/run-catalog.json as sparse JSON. Do not glob the diagnostic tree.", + schema(Map.of()), + args -> services.diagnosticCatalog()); + add("workbench_diagnostic_run", + "Read one run's run-index.json and clusters.json. Does not return events, traces, or screenshots.", + schema(Map.of("runId", stringProperty("Diagnostic run directory name from the catalog.")), "runId"), + args -> services.diagnosticRun(text(args, "runId"))); + add("workbench_diagnostic_summary", + "Read one scenario summary.json. Does not return events.jsonl, traces, or PNG bytes.", + schema(Map.of( + "runId", stringProperty("Diagnostic run directory name from the catalog."), + "scenarioId", stringProperty("Scenario directory name under that run.") + ), "runId", "scenarioId"), + args -> services.diagnosticScenarioSummary(text(args, "runId"), text(args, "scenarioId"))); } private void add( diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java index 13fe09d1..d88bdb9c 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java @@ -1,12 +1,15 @@ package tools.dscode.workbench.sync; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; /** Persisted synchronization provenance for one selected consumer project/module. */ +@JsonIgnoreProperties(ignoreUnknown = true) public record WorkbenchManifest( int schemaVersion, String projectRoot, @@ -21,16 +24,69 @@ public record WorkbenchManifest( List dependencyClasspath, String pickleballVersion, String javaVersion, - String javaHome + String javaHome, + String syncMode, + String javaInputFingerprint, + String resourceInputFingerprint, + String buildInputFingerprint, + String dependencyInputFingerprint ) { public static final int CURRENT_SCHEMA = 1; private static final ObjectMapper JSON = new ObjectMapper(); public WorkbenchManifest { - sourceRoots = List.copyOf(sourceRoots); - outputRoots = List.copyOf(outputRoots); - outputMappings = List.copyOf(outputMappings); - dependencyClasspath = List.copyOf(dependencyClasspath); + sourceRoots = List.copyOf(sourceRoots == null ? List.of() : sourceRoots); + outputRoots = List.copyOf(outputRoots == null ? List.of() : outputRoots); + outputMappings = List.copyOf(outputMappings == null ? List.of() : outputMappings); + dependencyClasspath = List.copyOf(dependencyClasspath == null ? List.of() : dependencyClasspath); + syncMode = syncMode == null || syncMode.isBlank() ? WorkbenchSyncMode.FULL.name() : syncMode; + javaInputFingerprint = javaInputFingerprint == null ? "" : javaInputFingerprint; + resourceInputFingerprint = resourceInputFingerprint == null ? "" : resourceInputFingerprint; + buildInputFingerprint = buildInputFingerprint == null ? "" : buildInputFingerprint; + dependencyInputFingerprint = dependencyInputFingerprint == null ? "" : dependencyInputFingerprint; + } + + public boolean hasInputFingerprints() { + return !javaInputFingerprint.isBlank() + && !resourceInputFingerprint.isBlank() + && !buildInputFingerprint.isBlank() + && !dependencyInputFingerprint.isBlank(); + } + + public WorkbenchManifest withSkip(String synchronizedAt, WorkbenchSyncInputs inputs) { + return new WorkbenchManifest( + schemaVersion, + projectRoot, + projectType, + buildTool, + sourceRoots, + outputRoots, + outputMappings, + liveOutput, + synchronizedAt, + fingerprint, + dependencyClasspath, + pickleballVersion, + javaVersion, + javaHome, + WorkbenchSyncMode.SKIPPED.name(), + inputs.javaFingerprint(), + inputs.resourceFingerprint(), + inputs.buildFingerprint(), + inputs.dependencyFingerprint() + ); + } + + static WorkbenchManifest readIfPresent(Path stateRoot) { + Path file = stateRoot.resolve("manifest.json"); + if (!Files.isRegularFile(file)) return null; + try { + WorkbenchManifest manifest = JSON.readValue(file.toFile(), WorkbenchManifest.class); + if (manifest.schemaVersion() != CURRENT_SCHEMA) return null; + return manifest; + } catch (IOException ignored) { + return null; + } } public static WorkbenchManifest read(Path projectRoot) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java new file mode 100644 index 00000000..db0b0b3b --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java @@ -0,0 +1,239 @@ +package tools.dscode.workbench.sync; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +/** + * Input-side fingerprints used to decide whether Workbench can skip the wrapper + * or refresh resources without a full test-compile. + * + *

These are distinct from {@link WorkbenchManifest#fingerprint()}, which + * remains output provenance over merged classes plus dependency artifact bytes.

+ */ +public record WorkbenchSyncInputs( + String javaFingerprint, + String resourceFingerprint, + String buildFingerprint, + String dependencyFingerprint, + List sourceRoots +) { + public WorkbenchSyncInputs { + javaFingerprint = javaFingerprint == null ? "" : javaFingerprint; + resourceFingerprint = resourceFingerprint == null ? "" : resourceFingerprint; + buildFingerprint = buildFingerprint == null ? "" : buildFingerprint; + dependencyFingerprint = dependencyFingerprint == null ? "" : dependencyFingerprint; + sourceRoots = List.copyOf(sourceRoots == null ? List.of() : sourceRoots); + } + + WorkbenchSyncInputs withDependencyFingerprint(String value) { + return new WorkbenchSyncInputs( + javaFingerprint, resourceFingerprint, buildFingerprint, value, sourceRoots + ); + } + + static WorkbenchSyncInputs capture(WorkbenchProject project, WorkbenchManifest previous) { + return capture( + project, + resolveSourceRoots(project, previous), + previous == null ? List.of() : previous.dependencyClasspath() + ); + } + + static WorkbenchSyncInputs capture( + WorkbenchProject project, + List sourceRoots, + List dependencies + ) { + List roots = sourceRoots == null || sourceRoots.isEmpty() + ? resolveSourceRoots(project, null) + : sourceRoots.stream().map(path -> path.toAbsolutePath().normalize()).toList(); + List javaFiles = new ArrayList<>(); + List resourceFiles = new ArrayList<>(); + for (Path root : roots) { + classify(root, javaFiles, resourceFiles); + } + return new WorkbenchSyncInputs( + fingerprintFiles(project.root(), javaFiles), + fingerprintFiles(project.root(), resourceFiles), + fingerprintFiles(project.root(), buildFiles(project)), + fingerprintDependencies(dependencies), + roots + ); + } + + static String fingerprintDependencies(List dependencies) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + List ordered = dependencies == null + ? List.of() + : dependencies.stream().sorted().toList(); + for (String dependency : ordered) { + if (dependency == null || dependency.isBlank()) continue; + Path path = Path.of(dependency).toAbsolutePath().normalize(); + digest.update(path.toString().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + if (Files.isRegularFile(path)) { + updateFileDigest(digest, path); + } else if (Files.isDirectory(path)) { + updateDirectoryDigest(digest, path); + } else { + digest.update("MISSING".getBytes(StandardCharsets.UTF_8)); + } + digest.update((byte) 0); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (Exception failure) { + throw new IllegalStateException("Could not fingerprint Workbench dependency inputs.", failure); + } + } + + static List resolveSourceRoots(WorkbenchProject project, WorkbenchManifest previous) { + if (previous != null && previous.sourceRoots() != null && !previous.sourceRoots().isEmpty()) { + return previous.sourceRoots().stream() + .map(value -> Path.of(value).toAbsolutePath().normalize()) + .toList(); + } + Path root = project.root(); + return List.of( + root.resolve("src/main/java"), + root.resolve("src/test/java"), + root.resolve("src/main/resources"), + root.resolve("src/test/resources") + ); + } + + static List buildFiles(WorkbenchProject project) { + List files = new ArrayList<>(); + Path root = project.root(); + Path buildRoot = project.buildRoot(); + if (project.type() == WorkbenchProject.Type.MAVEN) { + addIfFile(files, root.resolve("pom.xml")); + addIfFile(files, root.resolve(".mvn/maven.config")); + addIfFile(files, root.resolve(".mvn/jvm.config")); + } else { + addIfFile(files, root.resolve("build.gradle")); + addIfFile(files, root.resolve("build.gradle.kts")); + addIfFile(files, root.resolve("gradle.properties")); + addIfFile(files, buildRoot.resolve("settings.gradle")); + addIfFile(files, buildRoot.resolve("settings.gradle.kts")); + addIfFile(files, buildRoot.resolve("gradle.properties")); + addIfFile(files, buildRoot.resolve("gradle/libs.versions.toml")); + addIfFile(files, buildRoot.resolve("gradle/wrapper/gradle-wrapper.properties")); + } + return List.copyOf(files); + } + + static boolean compiledOutputsPresent(WorkbenchManifest previous) { + if (previous == null || previous.outputRoots() == null || previous.outputRoots().isEmpty()) { + return false; + } + long previousLiveClasses = countClassFiles(previous.liveOutputPath()); + long currentOutputClasses = 0; + for (WorkbenchManifest.OutputRoot output : previous.outputRoots()) { + Path path = Path.of(output.path()).toAbsolutePath().normalize(); + if (!Files.isDirectory(path)) { + return previousLiveClasses == 0; + } + currentOutputClasses += countClassFiles(path); + } + if (previousLiveClasses == 0) return true; + return currentOutputClasses >= previousLiveClasses; + } + + static long countClassFiles(Path root) { + if (root == null || !Files.isDirectory(root)) return 0; + try (Stream paths = Files.walk(root)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".class")) + .count(); + } catch (IOException ignored) { + return 0; + } + } + + static boolean snapshotReady(Path stateRoot) { + return Files.isDirectory(stateRoot.resolve("live").resolve("classes")) + && Files.isRegularFile(stateRoot.resolve("classpath.txt")) + && Files.isRegularFile(stateRoot.resolve("manifest.json")); + } + + private static void classify(Path root, List javaFiles, List resourceFiles) { + if (!Files.isDirectory(root)) return; + try (Stream paths = Files.walk(root)) { + paths.filter(Files::isRegularFile).forEach(file -> { + if (file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".java")) { + javaFiles.add(file); + } else { + resourceFiles.add(file); + } + }); + } catch (IOException failure) { + throw new IllegalStateException("Could not scan Workbench source root: " + root, failure); + } + } + + private static void addIfFile(List files, Path path) { + if (Files.isRegularFile(path)) files.add(path.toAbsolutePath().normalize()); + } + + private static String fingerprintFiles(Path projectRoot, List files) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + Path root = projectRoot.toAbsolutePath().normalize(); + List ordered = files.stream() + .map(path -> path.toAbsolutePath().normalize()) + .sorted(Comparator.comparing(path -> relativeKey(root, path))) + .toList(); + for (Path file : ordered) { + digest.update(relativeKey(root, file).getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + if (Files.isRegularFile(file)) { + updateFileDigest(digest, file); + } else { + digest.update("MISSING".getBytes(StandardCharsets.UTF_8)); + } + digest.update((byte) 0); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (Exception failure) { + throw new IllegalStateException("Could not fingerprint Workbench source inputs.", failure); + } + } + + private static String relativeKey(Path root, Path file) { + if (file.startsWith(root)) { + return root.relativize(file).toString().replace('\\', '/'); + } + return file.toString().replace('\\', '/'); + } + + private static void updateDirectoryDigest(MessageDigest digest, Path root) throws IOException { + try (var paths = Files.walk(root)) { + for (Path file : paths.filter(Files::isRegularFile).sorted().toList()) { + digest.update(root.relativize(file).toString().replace('\\', '/').getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + updateFileDigest(digest, file); + digest.update((byte) 0); + } + } + } + + private static void updateFileDigest(MessageDigest digest, Path file) throws IOException { + try (var input = Files.newInputStream(file)) { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) digest.update(buffer, 0, read); + } + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncMode.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncMode.java new file mode 100644 index 00000000..d5c80c3d --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncMode.java @@ -0,0 +1,11 @@ +package tools.dscode.workbench.sync; + +/** How much of the consumer build wrapper a Workbench synchronization invoked. */ +public enum WorkbenchSyncMode { + /** Maven `test-compile` / Gradle `testClasses` plus classpath metadata. */ + FULL, + /** Resource processing only; Java sources and dependencies were unchanged. */ + RESOURCES_ONLY, + /** Wrapper skipped; input fingerprints matched the last recorded snapshot. */ + SKIPPED +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java new file mode 100644 index 00000000..d10b81af --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java @@ -0,0 +1,34 @@ +package tools.dscode.workbench.sync; + +/** + * Chooses skip / resources-only / full compile from input fingerprints. + * + *

{@link WorkbenchManifest#fingerprint()} is output provenance and is never + * used as the skip key.

+ */ +public final class WorkbenchSyncPlanner { + private WorkbenchSyncPlanner() { + } + + public static WorkbenchSyncMode decide( + WorkbenchManifest previous, + WorkbenchSyncInputs current, + boolean snapshotReady + ) { + if (previous == null || current == null || !snapshotReady) { + return WorkbenchSyncMode.FULL; + } + if (!previous.hasInputFingerprints()) { + return WorkbenchSyncMode.FULL; + } + if (!current.javaFingerprint().equals(previous.javaInputFingerprint()) + || !current.buildFingerprint().equals(previous.buildInputFingerprint()) + || !current.dependencyFingerprint().equals(previous.dependencyInputFingerprint())) { + return WorkbenchSyncMode.FULL; + } + if (!current.resourceFingerprint().equals(previous.resourceInputFingerprint())) { + return WorkbenchSyncMode.RESOURCES_ONLY; + } + return WorkbenchSyncMode.SKIPPED; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java index 78d89dae..7b100a14 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java @@ -33,19 +33,47 @@ public final class WorkbenchSynchronizer { private static final ObjectMapper JSON = new ObjectMapper(); private static final String GRADLE_METADATA_PREFIX = "PKB_WORKBENCH_METADATA="; + @FunctionalInterface + interface CommandRunner { + String run(WorkbenchProject project, List args, Path log); + } + + private final CommandRunner commandRunner; + + public WorkbenchSynchronizer() { + this(null); + } + + WorkbenchSynchronizer(CommandRunner commandRunner) { + this.commandRunner = commandRunner == null ? this::runProcess : commandRunner; + } + public WorkbenchManifest sync(Path requestedProject) { WorkbenchProject project = WorkbenchProject.locate(requestedProject); Path stateRoot = WorkbenchManifest.workbenchRoot(project.root()); + WorkbenchManifest previous = WorkbenchManifest.readIfPresent(stateRoot); + WorkbenchSyncInputs inputs = WorkbenchSyncInputs.capture(project, previous); + WorkbenchSyncMode mode = WorkbenchSyncPlanner.decide( + previous, inputs, WorkbenchSyncInputs.snapshotReady(stateRoot) + ); + if (mode == WorkbenchSyncMode.RESOURCES_ONLY + && !WorkbenchSyncInputs.compiledOutputsPresent(previous)) { + mode = WorkbenchSyncMode.FULL; + } + if (mode == WorkbenchSyncMode.SKIPPED) { + return skip(previous, inputs, stateRoot); + } + Path logs = stateRoot.resolve("logs"); Path staging = stateRoot.resolve(".sync-" + UUID.randomUUID()); createDirectories(logs, staging); Path log = logs.resolve("sync-" + System.currentTimeMillis() + ".log"); try { - SyncMetadata metadata = project.type() == WorkbenchProject.Type.MAVEN - ? synchronizeMaven(project, staging, log) - : synchronizeGradle(project, staging, log); - return materialize(project, metadata, staging, stateRoot); + SyncMetadata metadata = mode == WorkbenchSyncMode.RESOURCES_ONLY + ? synchronizeResources(project, previous, log) + : synchronizeFull(project, staging, log); + return materialize(project, metadata, staging, stateRoot, mode); } finally { deleteTree(staging); } @@ -56,6 +84,22 @@ static WorkbenchManifest materialize( SyncMetadata metadata, Path staging, Path stateRoot + ) { + return materialize( + project, + metadata, + staging, + stateRoot, + WorkbenchSyncMode.FULL + ); + } + + static WorkbenchManifest materialize( + WorkbenchProject project, + SyncMetadata metadata, + Path staging, + Path stateRoot, + WorkbenchSyncMode mode ) { Path stagedBase = staging.resolve("base"); Path stagedBaseClasses = stagedBase.resolve("classes"); @@ -84,6 +128,9 @@ static WorkbenchManifest materialize( ); List dependencies = distinctExisting(metadata.dependencies()); + WorkbenchSyncInputs stored = WorkbenchSyncInputs.capture( + project, metadata.sourceRoots(), dependencies + ); String fingerprint = fingerprint(stagedBaseClasses, dependencies); Path finalBaseClasses = stateRoot.resolve("base").resolve("classes").toAbsolutePath().normalize(); Path finalLiveClasses = stateRoot.resolve("live").resolve("classes").toAbsolutePath().normalize(); @@ -116,7 +163,12 @@ static WorkbenchManifest materialize( dependencies, implementationVersion(), System.getProperty("java.version", "unknown"), - System.getProperty("java.home", "unknown") + System.getProperty("java.home", "unknown"), + mode == null ? WorkbenchSyncMode.FULL.name() : mode.name(), + stored.javaFingerprint(), + stored.resourceFingerprint(), + stored.buildFingerprint(), + stored.dependencyFingerprint() ); Path stagedManifest = staging.resolve("manifest.json"); @@ -156,10 +208,47 @@ public static List readWorkerClasspath(Path projectRoot) { } } - private SyncMetadata synchronizeMaven(WorkbenchProject project, Path staging, Path log) { + private WorkbenchManifest skip( + WorkbenchManifest previous, + WorkbenchSyncInputs inputs, + Path stateRoot + ) { + WorkbenchManifest updated = previous.withSkip(Instant.now().toString(), inputs); + updated.write(stateRoot.resolve("manifest.json")); + return updated; + } + + private SyncMetadata synchronizeFull(WorkbenchProject project, Path staging, Path log) { + return project.type() == WorkbenchProject.Type.MAVEN + ? synchronizeMavenFull(project, staging, log) + : synchronizeGradleFull(project, staging, log); + } + + private SyncMetadata synchronizeResources( + WorkbenchProject project, + WorkbenchManifest previous, + Path log + ) { + if (project.type() == WorkbenchProject.Type.MAVEN) { + commandRunner.run(project, mavenResourceArgs(project), log); + } else { + commandRunner.run(project, gradleResourceArgs(), log); + } + return metadataFrom(previous); + } + + private SyncMetadata synchronizeMavenFull(WorkbenchProject project, Path staging, Path log) { Path dependencyClasspath = staging.resolve("maven-classpath.txt"); Path effectivePom = staging.resolve("effective-pom.xml"); - List args = List.of( + commandRunner.run(project, mavenFullArgs(project, dependencyClasspath, effectivePom), log); + + MavenMetadata pom = parseEffectivePom(project.root(), effectivePom); + List dependencies = readClasspathValue(dependencyClasspath); + return new SyncMetadata(pom.sourceRoots(), pom.outputs(), dependencies); + } + + static List mavenFullArgs(WorkbenchProject project, Path dependencyClasspath, Path effectivePom) { + return List.of( "-f", project.root().resolve("pom.xml").toString(), "-DskipTests", "test-compile", @@ -170,14 +259,37 @@ private SyncMetadata synchronizeMaven(WorkbenchProject project, Path staging, Pa "org.apache.maven.plugins:maven-help-plugin:3.5.1:effective-pom", "-Doutput=" + effectivePom ); - run(project, args, log); + } + + static List mavenResourceArgs(WorkbenchProject project) { + return List.of( + "-f", project.root().resolve("pom.xml").toString(), + "-DskipTests", + "process-resources", + "process-test-resources" + ); + } - MavenMetadata pom = parseEffectivePom(project.root(), effectivePom); - List dependencies = readClasspathValue(dependencyClasspath); - return new SyncMetadata(pom.sourceRoots(), pom.outputs(), dependencies); + static List gradleResourceArgs() { + return List.of( + "processResources", + "processTestResources", + "--console=plain", + "-q" + ); + } + + private static SyncMetadata metadataFrom(WorkbenchManifest previous) { + List sources = previous.sourceRoots().stream() + .map(value -> Path.of(value).toAbsolutePath().normalize()) + .toList(); + List outputs = previous.outputRoots().stream() + .map(output -> new OutputPath(output.kind(), Path.of(output.path()))) + .toList(); + return new SyncMetadata(sources, outputs, previous.dependencyClasspath()); } - private SyncMetadata synchronizeGradle(WorkbenchProject project, Path staging, Path log) { + private SyncMetadata synchronizeGradleFull(WorkbenchProject project, Path staging, Path log) { Path initScript = staging.resolve("workbench-sync.init.gradle"); writeString(initScript, gradleInitScript()); List args = List.of( @@ -187,7 +299,7 @@ private SyncMetadata synchronizeGradle(WorkbenchProject project, Path staging, P "--console=plain", "-q" ); - String output = run(project, args, log); + String output = commandRunner.run(project, args, log); String metadataLine = output.lines() .filter(line -> line.startsWith(GRADLE_METADATA_PREFIX)) .reduce((first, second) -> second) @@ -234,7 +346,7 @@ static MavenMetadata parseEffectivePom(Path projectRoot, Path effectivePom) { } } - private String run(WorkbenchProject project, List args, Path log) { + private String runProcess(WorkbenchProject project, List args, Path log) { List command = executableCommand(project.launcher(), args); ProcessBuilder builder = new ProcessBuilder(command) .directory(project.buildRoot().toFile()) diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java index 72e6c52b..7c6b9944 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.sync.WorkbenchSyncMode; import java.nio.file.Files; import java.nio.file.Path; @@ -226,7 +227,12 @@ void prefersClasspathFeaturesConfigurationWithoutCrawlingUnrelatedDirectories() List.of(), "2.1.9", "21", - "/usr/lib/jvm" + "/usr/lib/jvm", + WorkbenchSyncMode.FULL.name(), + "java-fp", + "resource-fp", + "build-fp", + "dep-fp" ); ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, manifest); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java index 81ae6c50..3cba86ff 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java @@ -7,6 +7,7 @@ import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class DiagnosticEvidenceNavigatorTest { @@ -48,4 +49,42 @@ void readsCatalogThenIndexAndScreenshotFramesWithoutInventingRuns() throws Excep .anyMatch(layer -> layer.layer() == DiagnosticEvidenceNavigator.Layer.EVENTS && layer.present())); assertTrue(new DiagnosticEvidenceNavigator(project, project.resolve("missing")).catalogRuns().isEmpty()); } + + @Test + void sparseReadersReturnCatalogIndexClustersAndSummaryWithoutEventsOrScreenshots() throws Exception { + Path root = project.resolve("reports/diagnostic-runs"); + Path run = root.resolve("run-1"); + Path scenario = run.resolve("scenarios/scenario-1"); + Files.createDirectories(scenario.resolve("screenshots")); + Files.writeString(root.resolve("run-catalog.json"), """ + {"runs":[{"runId":"run-1","outcome":"FAILED"}]} + """); + Files.writeString(run.resolve("run-index.json"), """ + {"runId":"run-1","outcome":"FAILED","scenarioCount":1} + """); + Files.writeString(run.resolve("clusters.json"), """ + {"clusters":[{"id":"c1","size":1}]} + """); + Files.writeString(scenario.resolve("summary.json"), """ + {"scenarioId":"scenario-1","outcome":"FAILED","lastStepText":"Then stay"} + """); + Files.writeString(scenario.resolve("events.jsonl"), "{\"stepText\":\"secret-event\"}\n"); + Files.write(scenario.resolve("screenshots/frame-1.png"), new byte[]{9, 9, 9}); + + DiagnosticEvidenceNavigator navigator = new DiagnosticEvidenceNavigator(project); + String catalog = navigator.catalogDocument().toString(); + assertTrue(catalog.contains("run-1")); + assertFalse(catalog.contains("secret-event")); + + String runDocument = navigator.runDocument("run-1").toString(); + assertTrue(runDocument.contains("FAILED")); + assertTrue(runDocument.contains("\"clusters\"")); + assertFalse(runDocument.contains("secret-event")); + assertFalse(runDocument.contains("frame-1.png")); + + String summary = navigator.scenarioSummaryDocument("run-1", "scenario-1").toString(); + assertTrue(summary.contains("Then stay")); + assertFalse(summary.contains("secret-event")); + assertFalse(summary.contains("frame-1.png")); + } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java index a1096472..a2c6e053 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java @@ -18,6 +18,7 @@ import java.nio.file.Path; import java.time.Duration; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; @@ -67,6 +68,9 @@ void packagedServerInitializesListsToolsInvokesControllerAndKeepsStdoutProtocolO assertTrue(toolNames.contains("workbench_request_control")); assertTrue(toolNames.contains("workbench_player_state")); assertTrue(toolNames.contains("workbench_request_save")); + assertTrue(toolNames.contains("workbench_diagnostic_catalog")); + assertTrue(toolNames.contains("workbench_diagnostic_run")); + assertTrue(toolNames.contains("workbench_diagnostic_summary")); JsonNode status = harness.toolCall(3, "workbench_worker_status", "{}"); assertFalse(status.at("/result/isError").asBoolean()); @@ -129,6 +133,43 @@ void stepOverrideCompileUsesSharedServicesAndServerCloseIsIdempotent() throws Ex assertEquals(1, closes.get()); } + @Test + void diagnosticToolsDelegateToSharedServicesAndStaySparse() throws Exception { + AtomicBoolean catalogCalled = new AtomicBoolean(); + WorkbenchServices services = fakeServices((method, args) -> { + if ("diagnosticCatalog".equals(method)) { + catalogCalled.set(true); + return Map.of("available", true, "catalog", Map.of("runs", List.of(Map.of("runId", "run-1")))); + } + if ("diagnosticRun".equals(method)) { + assertEquals("run-1", args[0]); + return Map.of("runId", "run-1", "runIndex", Map.of("outcome", "FAILED")); + } + if ("diagnosticScenarioSummary".equals(method)) { + assertEquals("run-1", args[0]); + assertEquals("scenario-1", args[1]); + return Map.of("summary", Map.of("outcome", "FAILED")); + } + if ("close".equals(method)) return null; + return null; + }); + + WorkbenchMcpTools tools = new WorkbenchMcpTools(services, JSON); + assertTrue(tools.names().contains("workbench_diagnostic_catalog")); + Object catalog = tools.call("workbench_diagnostic_catalog", Map.of()); + assertTrue(catalogCalled.get()); + assertTrue(JSON.writeValueAsString(catalog).contains("run-1")); + assertFalse(JSON.writeValueAsString(catalog).contains("events.jsonl")); + + Object run = tools.call("workbench_diagnostic_run", Map.of("runId", "run-1")); + assertTrue(JSON.writeValueAsString(run).contains("FAILED")); + Object summary = tools.call("workbench_diagnostic_summary", Map.of( + "runId", "run-1", + "scenarioId", "scenario-1" + )); + assertTrue(JSON.writeValueAsString(summary).contains("FAILED")); + } + private static WorkbenchServices fakeServices(FakeInvocation invocation) { return (WorkbenchServices) Proxy.newProxyInstance( WorkbenchServices.class.getClassLoader(), diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java new file mode 100644 index 00000000..3875e328 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java @@ -0,0 +1,90 @@ +package tools.dscode.workbench.sync; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WorkbenchSyncPlannerTest { + + @Test + void missingPreviousOrSnapshotRequiresFullCompile() { + WorkbenchSyncInputs inputs = inputs("java", "res", "build", "dep"); + assertEquals(WorkbenchSyncMode.FULL, WorkbenchSyncPlanner.decide(null, inputs, true)); + assertEquals(WorkbenchSyncMode.FULL, WorkbenchSyncPlanner.decide(manifest(inputs), inputs, false)); + } + + @Test + void blankInputFingerprintsRequireFullCompile() { + WorkbenchManifest previous = manifest(new WorkbenchSyncInputs("", "", "", "", List.of())); + WorkbenchSyncInputs current = inputs("java", "res", "build", "dep"); + assertEquals(WorkbenchSyncMode.FULL, WorkbenchSyncPlanner.decide(previous, current, true)); + } + + @Test + void matchingInputFingerprintsSkipTheWrapper() { + WorkbenchSyncInputs inputs = inputs("java", "res", "build", "dep"); + assertEquals(WorkbenchSyncMode.SKIPPED, WorkbenchSyncPlanner.decide(manifest(inputs), inputs, true)); + } + + @Test + void resourceOnlyChangeSelectsResourcesOnly() { + WorkbenchManifest previous = manifest(inputs("java", "res", "build", "dep")); + WorkbenchSyncInputs current = inputs("java", "res-changed", "build", "dep"); + assertEquals(WorkbenchSyncMode.RESOURCES_ONLY, WorkbenchSyncPlanner.decide(previous, current, true)); + } + + @Test + void javaBuildOrDependencyChangeSelectsFullCompile() { + WorkbenchManifest previous = manifest(inputs("java", "res", "build", "dep")); + assertEquals( + WorkbenchSyncMode.FULL, + WorkbenchSyncPlanner.decide(previous, inputs("java-changed", "res", "build", "dep"), true) + ); + assertEquals( + WorkbenchSyncMode.FULL, + WorkbenchSyncPlanner.decide(previous, inputs("java", "res", "build-changed", "dep"), true) + ); + assertEquals( + WorkbenchSyncMode.FULL, + WorkbenchSyncPlanner.decide(previous, inputs("java", "res", "build", "dep-changed"), true) + ); + } + + @Test + void javaChangeWinsOverResourceChange() { + WorkbenchManifest previous = manifest(inputs("java", "res", "build", "dep")); + WorkbenchSyncInputs current = inputs("java-changed", "res-changed", "build", "dep"); + assertEquals(WorkbenchSyncMode.FULL, WorkbenchSyncPlanner.decide(previous, current, true)); + } + + private static WorkbenchSyncInputs inputs(String java, String resource, String build, String dependency) { + return new WorkbenchSyncInputs(java, resource, build, dependency, List.of(Path.of("src/test/java"))); + } + + private static WorkbenchManifest manifest(WorkbenchSyncInputs inputs) { + return new WorkbenchManifest( + WorkbenchManifest.CURRENT_SCHEMA, + "/project", + "MAVEN", + "mvn", + List.of("/project/src/test/java"), + List.of(new WorkbenchManifest.OutputRoot("TEST", "/project/target/test-classes")), + List.of(), + "/project/.pickleball/workbench/live/classes", + "2026-08-25T00:00:00Z", + "output-fp", + List.of("/repo/dep.jar"), + "2.1.9", + "21", + "/usr/lib/jvm", + WorkbenchSyncMode.FULL.name(), + inputs.javaFingerprint(), + inputs.resourceFingerprint(), + inputs.buildFingerprint(), + inputs.dependencyFingerprint() + ); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java index e9b18ff0..726b4d16 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java @@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -167,4 +168,201 @@ void detectsMavenAndGradleSelectedProjects() throws Exception { Files.writeString(gradle.resolve("build.gradle"), "plugins { id 'java' }"); assertEquals(WorkbenchProject.Type.GRADLE, WorkbenchProject.locate(gradle).type()); } + + @Test + void mavenFullArgsSkipTestsAndNeverInvokeSurefire() { + WorkbenchProject project = new WorkbenchProject( + tempDir.resolve("consumer").toAbsolutePath().normalize(), + WorkbenchProject.Type.MAVEN, + tempDir.resolve("consumer").toAbsolutePath().normalize(), + Path.of("mvn") + ); + List full = WorkbenchSynchronizer.mavenFullArgs( + project, tempDir.resolve("cp.txt"), tempDir.resolve("pom.xml") + ); + List resources = WorkbenchSynchronizer.mavenResourceArgs(project); + assertTrue(full.contains("-DskipTests")); + assertTrue(full.contains("test-compile")); + assertFalse(full.contains("test")); + assertTrue(resources.contains("-DskipTests")); + assertTrue(resources.contains("process-test-resources")); + assertFalse(resources.contains("test-compile")); + assertTrue(WorkbenchSynchronizer.gradleResourceArgs().contains("processTestResources")); + assertFalse(WorkbenchSynchronizer.gradleResourceArgs().contains("testClasses")); + } + + @Test + void skipsWrapperWhenJavaResourcesAndBuildAreUnchanged() throws Exception { + MavenFixture fixture = MavenFixture.create(tempDir.resolve("skip-consumer")); + RecordingRunner runner = fixture.runner(); + WorkbenchSynchronizer synchronizer = new WorkbenchSynchronizer(runner); + + WorkbenchManifest first = synchronizer.sync(fixture.projectRoot); + assertEquals(WorkbenchSyncMode.FULL.name(), first.syncMode()); + assertEquals(1, runner.invocations.size()); + assertTrue(runner.invocations.getFirst().contains("test-compile")); + assertTrue(first.hasInputFingerprints()); + + String liveMarker = Files.readString(Path.of(first.liveOutput()).resolve("Runner.class")); + WorkbenchManifest second = synchronizer.sync(fixture.projectRoot); + assertEquals(WorkbenchSyncMode.SKIPPED.name(), second.syncMode()); + assertEquals(1, runner.invocations.size()); + assertEquals(first.fingerprint(), second.fingerprint()); + assertEquals(liveMarker, Files.readString(Path.of(second.liveOutput()).resolve("Runner.class"))); + } + + @Test + void refreshesResourcesWithoutTestCompileWhenOnlyFeaturesChanged() throws Exception { + MavenFixture fixture = MavenFixture.create(tempDir.resolve("resource-consumer")); + RecordingRunner runner = fixture.runner(); + WorkbenchSynchronizer synchronizer = new WorkbenchSynchronizer(runner); + WorkbenchManifest first = synchronizer.sync(fixture.projectRoot); + assertEquals(WorkbenchSyncMode.FULL.name(), first.syncMode()); + + Files.writeString( + fixture.projectRoot.resolve("src/test/resources/features/demo.feature"), + "Feature: changed\n", + StandardCharsets.UTF_8 + ); + WorkbenchManifest second = synchronizer.sync(fixture.projectRoot); + assertEquals(WorkbenchSyncMode.RESOURCES_ONLY.name(), second.syncMode()); + assertEquals(2, runner.invocations.size()); + assertTrue(runner.invocations.get(1).contains("process-test-resources")); + assertFalse(runner.invocations.get(1).contains("test-compile")); + assertEquals( + "Feature: changed\n", + Files.readString(Path.of(second.liveOutput()).resolve("features/demo.feature")) + ); + assertTrue(Files.exists(Path.of(second.liveOutput()).resolve("Runner.class"))); + } + + @Test + void runsFullCompileWhenJavaSourceChanges() throws Exception { + MavenFixture fixture = MavenFixture.create(tempDir.resolve("java-consumer")); + RecordingRunner runner = fixture.runner(); + WorkbenchSynchronizer synchronizer = new WorkbenchSynchronizer(runner); + synchronizer.sync(fixture.projectRoot); + + Files.writeString( + fixture.projectRoot.resolve("src/test/java/Runner.java"), + "class Runner { int n = 2; }\n", + StandardCharsets.UTF_8 + ); + WorkbenchManifest second = synchronizer.sync(fixture.projectRoot); + assertEquals(WorkbenchSyncMode.FULL.name(), second.syncMode()); + assertEquals(2, runner.invocations.size()); + assertTrue(runner.invocations.get(1).contains("test-compile")); + } + + private static final class RecordingRunner implements WorkbenchSynchronizer.CommandRunner { + private final List> invocations = new ArrayList<>(); + private final MavenFixture fixture; + + private RecordingRunner(MavenFixture fixture) { + this.fixture = fixture; + } + + @Override + public String run(WorkbenchProject project, List args, Path log) { + invocations.add(List.copyOf(args)); + try { + if (args.contains("test-compile")) { + fixture.writeCompileOutputs(args); + } else if (args.contains("process-test-resources")) { + fixture.writeResourceOutputs(); + } + } catch (Exception failure) { + throw new IllegalStateException("Fake Maven runner failed.", failure); + } + return ""; + } + } + + private static final class MavenFixture { + private final Path projectRoot; + private final Path dependency; + private final RecordingRunner runner = new RecordingRunner(this); + + private MavenFixture(Path projectRoot, Path dependency) { + this.projectRoot = projectRoot; + this.dependency = dependency; + } + + static MavenFixture create(Path projectRoot) throws Exception { + Files.createDirectories(projectRoot.resolve("src/test/java")); + Files.createDirectories(projectRoot.resolve("src/test/resources/features")); + Files.writeString(projectRoot.resolve("pom.xml"), "", StandardCharsets.UTF_8); + Files.writeString( + projectRoot.resolve("src/test/java/Runner.java"), + "class Runner {}\n", + StandardCharsets.UTF_8 + ); + Files.writeString( + projectRoot.resolve("src/test/resources/features/demo.feature"), + "Feature: demo\n", + StandardCharsets.UTF_8 + ); + Path dependency = projectRoot.resolve("dep.jar"); + Files.write(dependency, new byte[]{1, 2, 3}); + return new MavenFixture(projectRoot.toAbsolutePath().normalize(), dependency); + } + + RecordingRunner runner() { + return runner; + } + + void writeCompileOutputs(List args) throws Exception { + Path main = projectRoot.resolve("target/classes"); + Path test = projectRoot.resolve("target/test-classes"); + Files.createDirectories(main); + Files.createDirectories(test.resolve("features")); + Files.writeString(test.resolve("Runner.class"), "class-bytes", StandardCharsets.UTF_8); + Files.writeString( + test.resolve("features/demo.feature"), + Files.readString(projectRoot.resolve("src/test/resources/features/demo.feature")), + StandardCharsets.UTF_8 + ); + Path classpath = outputFile(args, "-Dmdep.outputFile="); + Path effectivePom = outputFile(args, "-Doutput="); + Files.writeString(classpath, dependency.toString(), StandardCharsets.UTF_8); + Files.writeString(effectivePom, """ + + + + %s + %s + %s + %s + %s + %s + + + """.formatted( + projectRoot.resolve("src/main/java"), + projectRoot.resolve("src/test/java"), + projectRoot.resolve("src/main/resources"), + projectRoot.resolve("src/test/resources"), + main.toAbsolutePath(), + test.toAbsolutePath() + ), StandardCharsets.UTF_8); + } + + void writeResourceOutputs() throws Exception { + Path test = projectRoot.resolve("target/test-classes"); + Files.createDirectories(test.resolve("features")); + Files.writeString( + test.resolve("features/demo.feature"), + Files.readString(projectRoot.resolve("src/test/resources/features/demo.feature")), + StandardCharsets.UTF_8 + ); + } + + private static Path outputFile(List args, String prefix) { + return args.stream() + .filter(arg -> arg.startsWith(prefix)) + .map(arg -> Path.of(arg.substring(prefix.length()))) + .findFirst() + .orElseThrow(); + } + } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java index f8b6a237..57d58bb8 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java @@ -26,6 +26,7 @@ import tools.dscode.workbench.lease.WorkbenchControlLease; import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.sync.WorkbenchSyncMode; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; import java.lang.reflect.Proxy; @@ -310,7 +311,12 @@ private static WorkbenchManifest manifest() { List.of(), "2.1.8", "21", - Path.of("java-home").toAbsolutePath().normalize().toString() + Path.of("java-home").toAbsolutePath().normalize().toString(), + WorkbenchSyncMode.FULL.name(), + "java-fp", + "resource-fp", + "build-fp", + "dep-fp" ); } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java index a4456e10..90ccf940 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java @@ -5,6 +5,7 @@ import tools.dscode.control.protocol.ControlBridgeDescriptor; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.sync.WorkbenchSyncMode; import java.io.File; import java.nio.file.Path; @@ -211,7 +212,12 @@ private WorkbenchManifest manifest(Path project, Path live, Path dependency) { List.of(dependency.toString()), "2.1.8", "21", - System.getProperty("java.home") + System.getProperty("java.home"), + WorkbenchSyncMode.FULL.name(), + "java-fp", + "resource-fp", + "build-fp", + "dep-fp" ); } diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index 9160a940..ac261637 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -2,7 +2,53 @@ This is the canonical AI-agent contract for projects that consume Pickleball as a Maven dependency. -A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. +A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. Full `docs/` and the snapshot stay exported for on-demand lookup. Do not dump them into first-read context. + +## Tool chooser + +Use this order. Consumer AI agents for this Pickleball release use headless Workbench MCP (`mcp .`). Do not start the Workbench GUI. + +1. **Live headless MCP** — isolate a failing step in a paused worker. Reuse compilation, rewrite Gherkin in the live buffer, and inspect the page and semantic events in the same browser/Mapping state. +2. **One diagnostic `mvn test`** — after the live loop has isolated the failure, run one bounded confirmation with `pkb_runvars` so an evidence pack is retained. +3. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. + +Do not copy consumer features into `.pickleball` as a sandbox. + +### Live isolation loop + +From the consumer project, with Pickleball on the test classpath: + +1. Start the launcher with `mcp .` (not a GUI command). +2. Call `workbench_sync` once. The synchronizer skips Maven/Gradle when Java/build/dependencies are unchanged, and refreshes test resources without a full `test-compile` when only features/config/data changed. +3. `workbench_worker_start` — reuse the compiled live classpath; do not rebuild to start a worker. +4. `workbench_request_control` +5. Isolate with `workbench_execute_step` and/or `workbench_player_replace_document`. +6. Inspect with `workbench_browser_page`, `workbench_element_inspect`, and `workbench_events`. +7. When you need a retained evidence pack, run **one** diagnostic `mvn test` with `pkb_runvars` (below). Read that pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` instead of globbing `reports/diagnostic-runs`. + +`workbench_execute_step` failure does not end the worker. Insert, nest, or retry in the same paused browser/Mapping state. Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). + +Worker restart without rebuild already exists (`workbench_worker_restart`). Step Overrides compile worker-side (`workbench_step_override_compile`); they do not require Maven. + +### Generated trees are not the project + +- `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. +- `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. +- `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. + +## First-read + +Keep first-read small. After a successful export: + +1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. +2. Stay in this guide's tool chooser and live loop. +3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. +4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. +5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. + +Do not read `docs/README.md`, the whole `maven-consumer-project/` snapshot, or Workbench GUI pages as first actions. Those remain available on demand. + +The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. ## Generated guidance lifecycle @@ -19,26 +65,13 @@ The exporter does not create/commit a new `.gitignore`, alter the Git index, or Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Those leftovers are not authoritative for the downgraded dependency. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. -## First actions - -For Pickleball scenario authoring, configuration, execution, diagnostics, or troubleshooting: - -1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Read this guide before changing Pickleball scenarios or diagnosing a Pickleball run. -3. Use `docs/README.md` as the documentation map. -4. Inspect the consumer project's `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. -5. Use `maven-consumer-project/` as a version-matched read-only reference when a documented syntax/configuration example or working Pickleball consumer structure is useful. -6. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. - -The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. - ## Generated Maven consumer reference -`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. +`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. It is not the consumer project under test and is not a writable sandbox. The snapshot intentionally includes the consumer `pom.xml`, Pickleball runner, local browser/service test server, executable feature files, service-call definitions, configuration/data fixtures, local test-site resources, and the committed shared/local profile and property examples. It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` bridge, internal Java verification classes, and maintainer-only `_local2` files. -Use the snapshot to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not modify or execute files under `.pickleball/maven-consumer-project/` as the consumer project's implementation. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. +Use the snapshot only to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not copy, modify, or execute files under `.pickleball/maven-consumer-project/` as the project under test. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. `export-guidance` does not copy the consumer's own features into `.pickleball` for testing. ## Scenario authoring and fixes @@ -100,6 +133,22 @@ Never supply `pkb_run_profile` or `pkb_run_profile.` as input. They are When you launch Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative input. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. +For an agent's bounded confirmation `mvn test` (not the human runner defaults), include diagnostic evidence controls and keep the selection narrow. Documented AI defaults: + +```text +pkb_reportingmode=diagnostic +pkb_loglevel=warn +pkb_reportretention=failed +``` + +Use the narrowest `pkb_tags` / `pkb_name` that isolate the failure. Do not add the `pretty` plugin; it is console noise for agents. `pkb_reportretention=failed` keeps dense evidence for failing scenarios and does not retain it for passing ones. + +These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`). Example confirmation after a live-loop isolation: + +```text +mvn test -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + A selected profile or partial `pkb_runvars` input inherits only missing project execution-context RunVars: ```text @@ -139,6 +188,8 @@ Use this escalation order: Stop reading as soon as the current layer answers the investigation. Do not recursively ingest an entire diagnostic run. +From headless Workbench MCP, use `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` for layers 1–3 instead of globbing `reports/diagnostic-runs`. Those tools return sparse JSON only and do not dump `events.jsonl`, traces, or screenshot bytes. + ## Visual evidence rules - Never open a PNG merely to determine whether two screenshots differ. @@ -198,7 +249,7 @@ See `docs/ai-run-configuration.md` for the full profile/RunVar contract and `doc ## Pickleball syntax documentation -The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Use `docs/README.md` to select the relevant guide. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: +The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Open a specific guide when the live loop or a diagnostic layer requires that topic; do not start by reading `docs/README.md` as a dump. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: - dynamic Gherkin/action/assertion syntax — `docs/dynamic-steps.md`; - element vocabulary/selectors — `docs/custom-element-definitions.md`; @@ -217,7 +268,7 @@ Do not guess Pickleball syntax when the version-matched guide or executable cons ## Human-readable consumer guidance -Use `docs/consumer-project.md` for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Use `docs/README.md` to navigate the complete bundled documentation. Human readers can open files under `maven-consumer-project/` directly in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples linked from those guides. +Use `docs/consumer-project.md` on demand for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Human readers can start with `docs/README.md` and open files under `maven-consumer-project/` in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples. Agents should not treat those as first-read. ## When the core Pickleball repository is also present diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/README.md index 6f1ae4cd..3d8d3cb7 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/README.md @@ -2,7 +2,7 @@ Pickleball extends Cucumber with a dynamic feature-file language while preserving normal Cucumber behavior. The pages below describe the supported authoring model and link to real executable examples in [`maven-consumer-project`](consumer-project.md). -When these docs are materialized from the Maven dependency with `DiagnosticCli export-guidance`, links to `../maven-consumer-project/...` resolve to the version-matched, read-only reference snapshot exported beside the docs. Human readers and AI agents can therefore inspect the same working features, configuration, calls, data, runner, and local test-site examples without checking out the Pickleball source repository. +When these docs are materialized from the Maven dependency with `DiagnosticCli export-guidance`, links to `../maven-consumer-project/...` resolve to the version-matched, read-only reference snapshot exported beside the docs. Human readers can inspect those working features, configuration, calls, data, runner, and local test-site examples without checking out the Pickleball source repository. Consumer AI agents should follow `.pickleball/AGENT-GUIDE.md` first and open a specific guide only when needed. ## Start here diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index 8fcff1d1..e4121374 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -41,12 +41,12 @@ The standalone Workbench shadow JAR is controller-only and self-contained, with The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `PKB_CONTROL_BRIDGE_SESSION_ID`, `PKB_CONTROL_BRIDGE_TOKEN`, and `PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO`. Pickleball may also accept the prior `PKB_STUDIO_BRIDGE_*` names as deprecated compatibility input aliases. New Workbench code must use only the neutral names. -`WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. +`WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. Input fingerprints of Java sources, resources, build files, and dependency artifacts decide skip vs resources-only vs full compile; the output fingerprint in `manifest.json` remains provenance, not a skip key. Sync always passes `-DskipTests`. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. Live Gherkin buffer edits do not require sync. `WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through the protocol-owned worker class-name contract, without linking the worker class or invoking Maven/Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is the Text vs Blocks presentation choice for that same buffer; toggling it must not change document text, selection, or playhead id. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is Gherkin text or a block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is the Text vs Blocks presentation choice for that same buffer; toggling it must not change document text, selection, or playhead id. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use headless MCP (`mcp .`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` so a watcher can join a human GUI session instead of starting a second Workbench; that attach file is not the consumer-agent path for this release. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is Gherkin text or a block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 163dbf16..f44d9c18 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -505,6 +505,9 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncMode.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java` @@ -550,6 +553,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index 97ef5401..109a9fda 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -243,6 +243,12 @@ Example compact rerun: -Dpkb_changed_variables=pkb_browser ``` +For an agent's bounded confirmation `mvn test` (not `PickleballTests` human defaults of `pretty` / `@all`), include diagnostic evidence controls and keep selection narrow: + +```text +-Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + Lineage metadata is not execution configuration: ```text diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index 9160a940..ac261637 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -2,7 +2,53 @@ This is the canonical AI-agent contract for projects that consume Pickleball as a Maven dependency. -A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. +A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. Full `docs/` and the snapshot stay exported for on-demand lookup. Do not dump them into first-read context. + +## Tool chooser + +Use this order. Consumer AI agents for this Pickleball release use headless Workbench MCP (`mcp .`). Do not start the Workbench GUI. + +1. **Live headless MCP** — isolate a failing step in a paused worker. Reuse compilation, rewrite Gherkin in the live buffer, and inspect the page and semantic events in the same browser/Mapping state. +2. **One diagnostic `mvn test`** — after the live loop has isolated the failure, run one bounded confirmation with `pkb_runvars` so an evidence pack is retained. +3. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. + +Do not copy consumer features into `.pickleball` as a sandbox. + +### Live isolation loop + +From the consumer project, with Pickleball on the test classpath: + +1. Start the launcher with `mcp .` (not a GUI command). +2. Call `workbench_sync` once. The synchronizer skips Maven/Gradle when Java/build/dependencies are unchanged, and refreshes test resources without a full `test-compile` when only features/config/data changed. +3. `workbench_worker_start` — reuse the compiled live classpath; do not rebuild to start a worker. +4. `workbench_request_control` +5. Isolate with `workbench_execute_step` and/or `workbench_player_replace_document`. +6. Inspect with `workbench_browser_page`, `workbench_element_inspect`, and `workbench_events`. +7. When you need a retained evidence pack, run **one** diagnostic `mvn test` with `pkb_runvars` (below). Read that pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` instead of globbing `reports/diagnostic-runs`. + +`workbench_execute_step` failure does not end the worker. Insert, nest, or retry in the same paused browser/Mapping state. Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). + +Worker restart without rebuild already exists (`workbench_worker_restart`). Step Overrides compile worker-side (`workbench_step_override_compile`); they do not require Maven. + +### Generated trees are not the project + +- `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. +- `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. +- `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. + +## First-read + +Keep first-read small. After a successful export: + +1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. +2. Stay in this guide's tool chooser and live loop. +3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. +4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. +5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. + +Do not read `docs/README.md`, the whole `maven-consumer-project/` snapshot, or Workbench GUI pages as first actions. Those remain available on demand. + +The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. ## Generated guidance lifecycle @@ -19,26 +65,13 @@ The exporter does not create/commit a new `.gitignore`, alter the Git index, or Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Those leftovers are not authoritative for the downgraded dependency. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. -## First actions - -For Pickleball scenario authoring, configuration, execution, diagnostics, or troubleshooting: - -1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Read this guide before changing Pickleball scenarios or diagnosing a Pickleball run. -3. Use `docs/README.md` as the documentation map. -4. Inspect the consumer project's `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. -5. Use `maven-consumer-project/` as a version-matched read-only reference when a documented syntax/configuration example or working Pickleball consumer structure is useful. -6. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. - -The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. - ## Generated Maven consumer reference -`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. +`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. It is not the consumer project under test and is not a writable sandbox. The snapshot intentionally includes the consumer `pom.xml`, Pickleball runner, local browser/service test server, executable feature files, service-call definitions, configuration/data fixtures, local test-site resources, and the committed shared/local profile and property examples. It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` bridge, internal Java verification classes, and maintainer-only `_local2` files. -Use the snapshot to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not modify or execute files under `.pickleball/maven-consumer-project/` as the consumer project's implementation. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. +Use the snapshot only to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not copy, modify, or execute files under `.pickleball/maven-consumer-project/` as the project under test. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. `export-guidance` does not copy the consumer's own features into `.pickleball` for testing. ## Scenario authoring and fixes @@ -100,6 +133,22 @@ Never supply `pkb_run_profile` or `pkb_run_profile.` as input. They are When you launch Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative input. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. +For an agent's bounded confirmation `mvn test` (not the human runner defaults), include diagnostic evidence controls and keep the selection narrow. Documented AI defaults: + +```text +pkb_reportingmode=diagnostic +pkb_loglevel=warn +pkb_reportretention=failed +``` + +Use the narrowest `pkb_tags` / `pkb_name` that isolate the failure. Do not add the `pretty` plugin; it is console noise for agents. `pkb_reportretention=failed` keeps dense evidence for failing scenarios and does not retain it for passing ones. + +These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`). Example confirmation after a live-loop isolation: + +```text +mvn test -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + A selected profile or partial `pkb_runvars` input inherits only missing project execution-context RunVars: ```text @@ -139,6 +188,8 @@ Use this escalation order: Stop reading as soon as the current layer answers the investigation. Do not recursively ingest an entire diagnostic run. +From headless Workbench MCP, use `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` for layers 1–3 instead of globbing `reports/diagnostic-runs`. Those tools return sparse JSON only and do not dump `events.jsonl`, traces, or screenshot bytes. + ## Visual evidence rules - Never open a PNG merely to determine whether two screenshots differ. @@ -198,7 +249,7 @@ See `docs/ai-run-configuration.md` for the full profile/RunVar contract and `doc ## Pickleball syntax documentation -The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Use `docs/README.md` to select the relevant guide. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: +The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Open a specific guide when the live loop or a diagnostic layer requires that topic; do not start by reading `docs/README.md` as a dump. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: - dynamic Gherkin/action/assertion syntax — `docs/dynamic-steps.md`; - element vocabulary/selectors — `docs/custom-element-definitions.md`; @@ -217,7 +268,7 @@ Do not guess Pickleball syntax when the version-matched guide or executable cons ## Human-readable consumer guidance -Use `docs/consumer-project.md` for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Use `docs/README.md` to navigate the complete bundled documentation. Human readers can open files under `maven-consumer-project/` directly in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples linked from those guides. +Use `docs/consumer-project.md` on demand for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Human readers can start with `docs/README.md` and open files under `maven-consumer-project/` in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples. Agents should not treat those as first-read. ## When the core Pickleball repository is also present diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index 4633e07b..ace01d46 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -26,11 +26,11 @@ Rerun export before Pickleball work even when `.pickleball` already exists. A su Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Those leftovers are not authoritative for the downgraded dependency; prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Human readers can start with `.pickleball/docs/README.md`; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. That guide's tool chooser is the agent path: headless Workbench MCP (`mcp .`), one bounded diagnostic `mvn test`, then edits to the real consumer source. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md`; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot -`export-guidance` also materializes a curated, read-only snapshot of the canonical Pickleball Maven consumer under `.pickleball/maven-consumer-project/`. It is intended for both human readers and AI agents that need concrete working examples in addition to prose documentation. +`export-guidance` also materializes a curated, read-only snapshot of the canonical Pickleball Maven consumer under `.pickleball/maven-consumer-project/`. It is a version-matched **reference** of Pickleball's own example consumer for on-demand lookup, not a sandbox and not the consumer project under test. `export-guidance` does not copy the current consumer's own features into `.pickleball` for testing. The snapshot includes: @@ -42,7 +42,7 @@ The snapshot includes: - static local test-site resources; and - the committed shared/local `profiles*.yaml` and `pickleball*.properties` examples. -It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` and `.github/copilot-instructions.md` bridges, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to edit or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. +It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` and `.github/copilot-instructions.md` bridges, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to copy, edit, or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. `.pickleball/workbench/live/classes` is a compiled worker overlay, not an editor. ## Purpose @@ -77,20 +77,20 @@ or use the included wrappers: ## Launch the dependency-matched Workbench -The test-scoped Pickleball dependency already contains its controller-only Workbench payload. Start the launcher from the resolved test classpath without finding a Maven cache entry or declaring a second version: +The test-scoped Pickleball dependency already contains its controller-only Workbench payload. Consumer AI agents start the **headless MCP** launcher from the resolved test classpath: ```bash ./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ - "-Dexec.args=ui ." + "-Dexec.args=mcp ." ``` ```powershell -.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -The launcher verifies and extracts the opaque payload beneath `.pickleball/workbench/controller//`, then creates a separate Workbench JVM. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. UI and MCP both use the same controller/live session. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. +Humans who want the Swing player can pass `ui .` instead. Agents for this release should not use the GUI, `ui .`, or `attach.json` as their path. The launcher verifies and extracts the opaque payload beneath `.pickleball/workbench/controller//`, then creates a separate Workbench JVM. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. Runner defaults include: @@ -152,6 +152,12 @@ mvn test -Dpkb_tags="@forms and @state-assertions" mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" ``` +Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a bounded confirmation should not reuse those defaults. Use a separate `pkb_runvars` command, for example: + +```bash +mvn test -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + The consumer `pom.xml` also defines Maven profiles such as: ```bash @@ -260,4 +266,4 @@ When evidence supports a bounded rerun: - Port `8765` must be available for the example test server. - Nested README/AGENTS files are minimal adapters; detailed guidance and version-matched reference examples are owned by Pickleball core and exported from the dependency. -Use `docs/README.md` for the complete version-matched Pickleball syntax/documentation map and `maven-consumer-project/` for the corresponding working reference files. +Human readers can use `docs/README.md` for the complete version-matched Pickleball syntax/documentation map and `maven-consumer-project/` for the corresponding working reference files. Agents should not treat those as first-read. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index 8df90b18..950c8f65 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -43,20 +43,22 @@ The normal `tools.dscode:pickleball:` test dependency already carries t META-INF/pickleball/workbench/pickleball-workbench.jar ``` -Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version: +Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version. + +Consumer AI agents use headless MCP: ```bash mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ - "-Dexec.args=ui ." + "-Dexec.args=mcp ." ``` ```powershell -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -With no launcher arguments, `ui` and the current directory are selected automatically. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."` or `"-Dexec.args=mcp ."`. +Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Agents for this release should not use the GUI, `ui .`, or `.pickleball/workbench/attach.json` as their path; see `.pickleball/AGENT-GUIDE.md`. Gradle consumers can expose the same dependency-owned launcher without resolving a cache path or adding a Workbench dependency: @@ -64,7 +66,7 @@ Gradle consumers can expose the same dependency-owned launcher without resolving tasks.register('pickleballWorkbench', JavaExec) { classpath = sourceSets.test.runtimeClasspath mainClass = 'tools.dscode.launcher.PickleballWorkbenchLauncher' - args 'ui', projectDir.absolutePath + args 'mcp', projectDir.absolutePath // consumer agents; pass 'ui' for the Swing player } ``` @@ -99,7 +101,15 @@ $workbenchJar = ".\pickleball-workbench\build\libs\pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. - -From `maven-consumer-project`: - -```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ - -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ - -Dexec.classpathScope=test \ - "-Dexec.args=ui ." -``` +Headless `java -jar pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That is the consumer-agent path. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. -Then point the agent at `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session. +A human-watched UI session is optional and separate. From `maven-consumer-project`, a person may start `ui .` and then a watcher can join `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session, and do not treat that attach file as the default agent path. ## MCP stdio -Start the lightweight non-Spring MCP server for a synchronized consumer project: +Start the lightweight non-Spring MCP server for a consumer project. This is the consumer-agent path: ```powershell java -jar $workbenchJar mcp ".\maven-consumer-project" ``` +Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."`. Do not document or use the Swing GUI as the agent path. + The server uses the official Java MCP SDK core and stdio transport with the Jackson 2 JSON adapter. MCP dependencies are Workbench-only and are shaded into the executable companion. Workbench deliberately does not use Spring Boot, Spring Framework, Spring AI, WebMVC, or Tomcat. ### Stdout contract @@ -345,6 +350,8 @@ workbench_worker_stop workbench_worker_status ``` +`workbench_sync` uses the skip / resources-only / full rules above. Live buffer edits do not require it. + Live runtime, Mapping, and watched-agent control: ```text @@ -391,6 +398,14 @@ workbench_step_override_remove workbench_step_override_clear ``` +Sparse diagnostic readers (do not glob `reports/diagnostic-runs`; these return JSON only and do not dump events, traces, or PNG bytes): + +```text +workbench_diagnostic_catalog +workbench_diagnostic_run +workbench_diagnostic_summary +``` + `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. From a266c18ab52c80f8d77cb8ee384008e50efa2291 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 06:15:09 +0000 Subject: [PATCH 14/15] Add consumer-agent investigation HTML handoff. Agents emit a small JSON+HTML report under .pickleball/investigations// after the live isolation loop and one diagnostic rerun. DiagnosticCli emit-investigation and workbench_investigation_emit write the pair, link at most two existing screenshots, and print only the relative report.html path. export-guidance leaves investigations unmanaged. Co-authored-by: ds-code-t --- docs/agent/feature-map.md | 8 +- docs/agent/repository-index.md | 3 + docs/ai-diagnostic-reporting-plan.md | 1 + docs/consumer-agent-guide.md | 12 +- docs/consumer-project.md | 1 + docs/diagnostic-reporting.md | 31 ++ docs/pickleball-workbench.md | 6 + .../InternalFrameworkTestSteps.java | 4 +- .../InvestigationHandoffChecks.java | 146 +++++ .../diagnostic/PickleballGuidanceChecks.java | 46 ++ .../protocol/InvestigationHandoff.java | 527 ++++++++++++++++++ pickleball-workbench/AGENTS.md | 2 +- .../dscode/workbench/WorkbenchController.java | 11 + .../dscode/workbench/WorkbenchServices.java | 3 + .../workbench/mcp/WorkbenchMcpTools.java | 32 ++ .../diagnostics/InvestigationHandoffTest.java | 114 ++++ .../workbench/mcp/WorkbenchMcpServerTest.java | 16 + .../reporting/diagnostic/DiagnosticCli.java | 46 +- .../pickleball/guidance/AGENT-GUIDE.md | 12 +- .../guidance/docs/agent/feature-map.md | 8 +- .../docs/ai-diagnostic-reporting-plan.md | 1 + .../guidance/docs/consumer-agent-guide.md | 12 +- .../guidance/docs/consumer-project.md | 1 + .../guidance/docs/diagnostic-reporting.md | 31 ++ .../guidance/docs/pickleball-workbench.md | 6 + 25 files changed, 1060 insertions(+), 20 deletions(-) create mode 100644 maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index e4121374..0dd38a0e 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -5,8 +5,8 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Capability | Implementation/search anchors | Consumer/internal coverage | Canonical documentation | |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | -| Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | +| Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -19,8 +19,8 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/config-files-and-resource-mapping.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements` | `mapping-and-resources.feature`; `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; Data Element features; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md` | | Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_run_profile`, `pkb_configpath` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | -| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `GUIDANCE-MANIFEST.json` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | -| Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; diagnostic aspects; `DiagnosticCli.java`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | +| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | +| Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff`; diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | | Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation | `nested-and-block-conditionals.feature` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index f44d9c18..fbf3f914 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -470,6 +470,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java` ## Pickleball Workbench module @@ -542,6 +543,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` @@ -590,6 +592,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/tools/dscode/common/mappings/MappingDataRefactorChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/Diagnostic213CompletionChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/DiagnosticReportingChecks.java` +- `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/util/datetime/BusinessTemporalDeltaChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/util/datetime/BusinessTimePostModifierChecks.java` diff --git a/docs/ai-diagnostic-reporting-plan.md b/docs/ai-diagnostic-reporting-plan.md index 11b6382e..2d9c4278 100644 --- a/docs/ai-diagnostic-reporting-plan.md +++ b/docs/ai-diagnostic-reporting-plan.md @@ -132,6 +132,7 @@ For a source-only fix, reuse the retained RunVars unchanged and omit `pkb_change ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index ac261637..55a833d5 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -10,7 +10,8 @@ Use this order. Consumer AI agents for this Pickleball release use headless Work 1. **Live headless MCP** — isolate a failing step in a paused worker. Reuse compilation, rewrite Gherkin in the live buffer, and inspect the page and semantic events in the same browser/Mapping state. 2. **One diagnostic `mvn test`** — after the live loop has isolated the failure, run one bounded confirmation with `pkb_runvars` so an evidence pack is retained. -3. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. +3. **Emit the human handoff** — write `.pickleball/investigations//` then in chat print only `.pickleball/investigations//report.html`. +4. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. Do not copy consumer features into `.pickleball` as a sandbox. @@ -25,6 +26,7 @@ From the consumer project, with Pickleball on the test classpath: 5. Isolate with `workbench_execute_step` and/or `workbench_player_replace_document`. 6. Inspect with `workbench_browser_page`, `workbench_element_inspect`, and `workbench_events`. 7. When you need a retained evidence pack, run **one** diagnostic `mvn test` with `pkb_runvars` (below). Read that pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` instead of globbing `reports/diagnostic-runs`. +8. Emit the human handoff with `workbench_investigation_emit` or `DiagnosticCli emit-investigation`. In chat print only `.pickleball/investigations//report.html`. Do not paste the report body, cause/fix essays, or screenshots into the chat panel. `workbench_execute_step` failure does not end the worker. Insert, nest, or retry in the same paused browser/Mapping state. Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). @@ -34,6 +36,7 @@ Worker restart without rebuild already exists (`workbench_worker_restart`). Step - `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. - `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. +- `.pickleball/investigations/` is unmanaged consumer-agent output. `export-guidance` leaves it alone. - `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. ## First-read @@ -58,7 +61,7 @@ A successful `export-guidance .pickleball` run: - overwrites the current version's managed guidance files, documentation, and Maven consumer reference snapshot; - writes `.pickleball/GUIDANCE-MANIFEST.json` last, recording the exporting Pickleball version and managed files; -- removes files managed by the previous manifest that are no longer shipped, while leaving unrelated files alone; and +- removes files managed by the previous manifest that are no longer shipped, while leaving unrelated files alone, including `.pickleball/investigations/`; and - best-effort ensures `.pickleball` is ignored by Git, preferring an existing `.gitignore` and then repository-local `.git/info/exclude`. The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat any existing `.pickleball` contents as potentially stale. The manifest records the last completed export; it is not a substitute for rerunning the exporter. @@ -190,6 +193,8 @@ Stop reading as soon as the current layer answers the investigation. Do not recu From headless Workbench MCP, use `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` for layers 1–3 instead of globbing `reports/diagnostic-runs`. Those tools return sparse JSON only and do not dump `events.jsonl`, traces, or screenshot bytes. +After isolation and the diagnostic rerun, emit a small human handoff. JSON is the source of truth; HTML is a local render of that JSON plus at most two screenshots linked from the existing diagnostic pack. Do not copy the diagnostic run into `.pickleball/investigations/`. In chat print only the project-relative `report.html` path. + ## Visual evidence rules - Never open a PNG merely to determine whether two screenshots differ. @@ -208,12 +213,13 @@ From a Maven consumer where Pickleball is on the test classpath: ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild ``` -Use `guidance` to print this guide and `export-guidance` to materialize the complete version-matched documentation plus curated Maven consumer reference. Prefer `DiagnosticCli` over constructing Maven classpaths and JShell scripts for routine diagnostic operations. +Use `guidance` to print this guide and `export-guidance` to materialize the complete version-matched documentation plus curated Maven consumer reference. Prefer `DiagnosticCli` over constructing Maven classpaths and JShell scripts for routine diagnostic operations. `emit-investigation` writes `.pickleball/investigations//investigation.json` and `report.html` and prints the relative HTML path. ## Controlled diagnostic reruns diff --git a/docs/consumer-project.md b/docs/consumer-project.md index ace01d46..35845161 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -235,6 +235,7 @@ Do not recursively ingest an entire run. ```text DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] +DiagnosticCli emit-investigation DiagnosticCli rebuild ``` diff --git a/docs/diagnostic-reporting.md b/docs/diagnostic-reporting.md index 60e4a50e..f5cabd71 100644 --- a/docs/diagnostic-reporting.md +++ b/docs/diagnostic-reporting.md @@ -119,6 +119,7 @@ Supported command-line operations: ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild @@ -126,6 +127,36 @@ DiagnosticCli rebuild Prefer `DiagnosticCli` over custom Maven-classpath/JShell workflows for routine comparison and recovery. +`emit-investigation` writes a small human handoff under the consumer project: + +```text +.pickleball/investigations// + investigation.json # source of truth + report.html # one-page local render +``` + +Input is investigation JSON from a file or stdin (`-`) plus the consumer project root. The command prints the project-relative `report.html` path. JSON is the source of truth. HTML renders that JSON plus at most two screenshots *linked* from the existing diagnostic pack; extra screenshot paths are ignored, and a missing image becomes a short note rather than a failed emit. The writer does not copy `reports/diagnostic-runs/` and does not change `pkb_diagnostic_output`. Headless Workbench MCP exposes the same emit as `workbench_investigation_emit` and returns only that relative report path. + +Suggested investigation JSON fields, using existing lineage/diagnostic names where they already exist: + +```text +pkb_investigation_id +createdAt +scenario.name / scenario.feature / scenario.scenarioId +outcome # cause-only | cause-and-fix +cause +fix # text, or "not fixed" +category # selector | gherkin | java | data | other +failureSignature +failureSite +runId +runIndexPath # pointer, not a copy +screenshots # at most two project-relative PNG paths +pickleballVersion +``` + +`export-guidance` does not manage or delete `.pickleball/investigations/`. + ## Outcomes and completion Run outcomes: diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 950c8f65..d376c7e2 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -406,6 +406,12 @@ workbench_diagnostic_run workbench_diagnostic_summary ``` +Human investigation handoff (writes `.pickleball/investigations//` and returns the relative `report.html` path only): + +```text +workbench_investigation_emit +``` + `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java index 9f0483d7..a7d16c46 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java @@ -12,6 +12,7 @@ import tools.dscode.common.mappings.MappingDataRefactorChecks; import tools.dscode.common.reporting.diagnostic.Diagnostic213CompletionChecks; import tools.dscode.common.reporting.diagnostic.DiagnosticReportingChecks; +import tools.dscode.common.reporting.diagnostic.InvestigationHandoffChecks; import tools.dscode.common.reporting.diagnostic.PickleballGuidanceChecks; import tools.dscode.common.reporting.diagnostic.ReportRetentionPolicy; import tools.dscode.common.util.datetime.BusinessTemporalDeltaChecks; @@ -55,7 +56,8 @@ public static void runDiagnosticReportingJavaTests() { runAndAssert( DiagnosticReportingChecks.class, Diagnostic213CompletionChecks.class, - PickleballGuidanceChecks.class + PickleballGuidanceChecks.class, + InvestigationHandoffChecks.class ); } finally { ReportRetentionPolicy.clearThreadOverride(); diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java new file mode 100644 index 00000000..9ff5a12d --- /dev/null +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java @@ -0,0 +1,146 @@ +package tools.dscode.common.reporting.diagnostic; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import tools.dscode.control.protocol.InvestigationHandoff; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class InvestigationHandoffChecks { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void jsonToHtmlEscapesTextCapsScreenshotsAndNotesMissingImages() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-html"); + try { + Path shots = project.resolve("reports/diagnostic-runs/run-1/scenarios/s1/screenshots"); + Files.createDirectories(shots); + Files.write(shots.resolve("ok.png"), new byte[]{1, 2, 3}); + + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "form-1"); + raw.put("scenario", Map.of("name", "Click ")); + raw.put("cause", "Looked for & clicked the wrong one."); + raw.put("screenshots", List.of( + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/ok.png", + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/gone.png", + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/third.png" + )); + + InvestigationHandoff.Document document = InvestigationHandoff.normalize(raw, project); + assertEquals(2, document.screenshots().size()); + String html = InvestigationHandoff.renderHtml(document, project); + assertTrue(html.contains("Looked for <input> & clicked the wrong one.")); + assertTrue(html.contains("Click <Go>")); + assertTrue(html.contains("ok.png")); + assertTrue(html.contains("Screenshot missing: reports/diagnostic-runs/run-1/scenarios/s1/screenshots/gone.png")); + assertFalse(html.contains("third.png")); + assertFalse(html.contains("")); + } finally { + deleteTree(project); + } + } + + @Test + void diagnosticCliEmitWritesHandoffPairAndDoesNotCopyTheDiagnosticPack() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-cli"); + try { + Path run = project.resolve("reports/diagnostic-runs/run-9/scenarios/s1/screenshots"); + Files.createDirectories(run); + Path png = run.resolve("frame.png"); + Files.write(png, new byte[]{8, 8, 8}); + Path index = project.resolve("reports/diagnostic-runs/run-9/run-index.json"); + Files.writeString(index, "{\"runId\":\"run-9\"}", StandardCharsets.UTF_8); + + Path input = project.resolve("handoff.json"); + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "cli-9"); + raw.put("cause", "The catalog button was stale."); + raw.put("outcome", "cause-only"); + raw.put("runId", "run-9"); + raw.put("failureSignature", "stale-element"); + raw.put("screenshots", List.of( + "reports/diagnostic-runs/run-9/scenarios/s1/screenshots/frame.png" + )); + JSON.writeValue(input.toFile(), raw); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int status = DiagnosticCli.run( + new String[]{"emit-investigation", input.toString(), project.toString()}, + new PrintStream(output, true, StandardCharsets.UTF_8), + System.err + ); + assertEquals(0, status); + String reportPath = output.toString(StandardCharsets.UTF_8).trim(); + assertEquals(".pickleball/investigations/cli-9/report.html", reportPath); + + Path jsonFile = project.resolve(".pickleball/investigations/cli-9/investigation.json"); + Path htmlFile = project.resolve(".pickleball/investigations/cli-9/report.html"); + assertTrue(Files.isRegularFile(jsonFile)); + assertTrue(Files.isRegularFile(htmlFile)); + assertTrue(Files.isRegularFile(png)); + assertTrue(Files.isRegularFile(index)); + + String json = Files.readString(jsonFile, StandardCharsets.UTF_8); + assertTrue(json.contains("\"pkb_investigation_id\" : \"cli-9\"") + || json.contains("\"pkb_investigation_id\": \"cli-9\"")); + assertTrue(json.contains("stale-element")); + assertFalse(json.contains("iVBORw0KGgo")); + + try (var paths = Files.walk(project.resolve(".pickleball/investigations"))) { + List files = paths.filter(Files::isRegularFile).toList(); + assertEquals(2, files.size()); + } + + String html = Files.readString(htmlFile, StandardCharsets.UTF_8); + assertTrue(html.contains("../../../reports/diagnostic-runs/run-9/scenarios/s1/screenshots/frame.png")); + assertTrue(html.contains("not fixed")); + } finally { + deleteTree(project); + } + } + + @Test + void diagnosticCliReadsInvestigationJsonFromStdin() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-stdin"); + try { + String json = """ + {"pkb_investigation_id":"stdin-1","cause":"A mapping key was wrong."} + """; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int status = DiagnosticCli.run( + new String[]{"emit-investigation", "-", project.toString()}, + new PrintStream(output, true, StandardCharsets.UTF_8), + System.err, + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)) + ); + assertEquals(0, status); + assertEquals(".pickleball/investigations/stdin-1/report.html", output.toString(StandardCharsets.UTF_8).trim()); + assertTrue(Files.isRegularFile(project.resolve(".pickleball/investigations/stdin-1/investigation.json"))); + } finally { + deleteTree(project); + } + } + + private static void deleteTree(Path root) throws Exception { + if (root == null || !Files.exists(root)) return; + try (var paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java index 956968a3..65c6ba1f 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java @@ -46,6 +46,8 @@ void dependencyPrintsCanonicalAgentGuide() { assertTrue(guide.contains("workbench_sync")); assertTrue(guide.contains("workbench_execute_step")); assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("workbench_investigation_emit")); + assertTrue(guide.contains("emit-investigation")); assertTrue(guide.contains("pkb_reportingmode=diagnostic")); assertTrue(guide.contains("pkb_reportretention=failed")); String chooser = guide.substring(0, guide.indexOf("Generated guidance lifecycle")); @@ -136,6 +138,7 @@ void dependencyExportsVersionMatchedGuidanceAndManifest() throws Exception { assertTrue(guide.contains("read-only reference snapshot")); assertTrue(guide.contains("mcp .")); assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("workbench_investigation_emit")); assertTrue(guide.contains("pkb_reportretention=failed")); assertTrue(guide.contains("Do not copy, modify, or execute files")); @@ -214,6 +217,49 @@ void exportRemovesObsoleteManagedFilesButPreservesUnmanagedFiles() throws Except } } + @Test + void exportDoesNotDeleteUnmanagedInvestigationsDirectory() throws Exception { + Path consumer = Files.createTempDirectory("pickleball-guidance-investigations"); + Path root = consumer.resolve(".pickleball"); + try { + assertEquals(0, DiagnosticCli.run( + new String[]{"export-guidance", root.toString()}, + System.out, + System.err + )); + + Path investigation = root.resolve("investigations/keep-me/investigation.json"); + Path report = root.resolve("investigations/keep-me/report.html"); + Path empty = root.resolve("investigations/empty"); + Files.createDirectories(investigation.getParent()); + Files.createDirectories(empty); + Files.writeString(investigation, "{\"pkb_investigation_id\":\"keep-me\"}", StandardCharsets.UTF_8); + Files.writeString(report, "keep", StandardCharsets.UTF_8); + + Map manifest = readManifest(root); + List managedFiles = new ArrayList<>(asStringList(manifest.get("files"))); + managedFiles.add("investigations/keep-me/investigation.json"); + managedFiles.add("investigations/keep-me/report.html"); + manifest.put("files", managedFiles); + JSON.writeValue(root.resolve("GUIDANCE-MANIFEST.json").toFile(), manifest); + + assertEquals(0, DiagnosticCli.run( + new String[]{"export-guidance", root.toString()}, + System.out, + System.err + )); + + assertTrue(Files.isRegularFile(investigation)); + assertTrue(Files.isRegularFile(report)); + assertTrue(Files.isDirectory(empty)); + assertEquals("{\"pkb_investigation_id\":\"keep-me\"}", Files.readString(investigation, StandardCharsets.UTF_8)); + Map next = readManifest(root); + assertFalse(asStringList(next.get("files")).stream().anyMatch(path -> path.contains("investigations/"))); + } finally { + deleteTree(consumer); + } + } + @Test void exportAddsPickleballToExistingConsumerGitignoreWithoutDuplicates() throws Exception { Path consumer = Files.createTempDirectory("pickleball-guidance-ignore"); diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java new file mode 100644 index 00000000..cbd540e7 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java @@ -0,0 +1,527 @@ +package tools.dscode.control.protocol; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Shared JDK-only writer for consumer-agent investigation handoffs. + * + *

This is not Control Bridge wire protocol. It lives in the protocol module so + * Pickleball {@code DiagnosticCli} and Workbench MCP can emit identical reports + * without giving Workbench a core Pickleball dependency.

+ * + *

{@code investigation.json} is the source of truth. {@code report.html} is a + * local render of that JSON plus at most two screenshots linked from the + * existing diagnostic pack. The writer never copies diagnostic-run files and + * never embeds PNG bytes.

+ */ +public final class InvestigationHandoff { + public static final int MAX_SCREENSHOTS = 2; + public static final int SCHEMA_VERSION = 1; + public static final String INVESTIGATIONS_DIRECTORY = "investigations"; + public static final String RELATIVE_ROOT = ".pickleball/" + INVESTIGATIONS_DIRECTORY; + public static final String NOT_FIXED = "not fixed"; + public static final String OUTCOME_CAUSE_ONLY = "cause-only"; + public static final String OUTCOME_CAUSE_AND_FIX = "cause-and-fix"; + + private static final Pattern INVESTIGATION_ID = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private static final int MAX_PATH_CHARS = 1024; + + private InvestigationHandoff() { + } + + public record Document( + String investigationId, + String createdAt, + String scenarioName, + String feature, + String scenarioId, + String outcome, + String cause, + String fix, + String category, + String failureSignature, + Object failureSite, + String runId, + String runIndexPath, + List screenshots, + String pickleballVersion + ) { + public Document { + screenshots = List.copyOf(screenshots == null ? List.of() : screenshots); + } + + public Map toMap() { + Map map = new LinkedHashMap<>(); + map.put("schemaVersion", SCHEMA_VERSION); + map.put("pkb_investigation_id", investigationId); + map.put("createdAt", createdAt); + Map scenario = new LinkedHashMap<>(); + putIfPresent(scenario, "name", scenarioName); + putIfPresent(scenario, "feature", feature); + putIfPresent(scenario, "scenarioId", scenarioId); + if (!scenario.isEmpty()) map.put("scenario", scenario); + map.put("outcome", outcome); + map.put("cause", cause); + map.put("fix", fix); + putIfPresent(map, "category", category); + putIfPresent(map, "failureSignature", failureSignature); + if (failureSite != null) map.put("failureSite", failureSite); + putIfPresent(map, "runId", runId); + putIfPresent(map, "runIndexPath", runIndexPath); + map.put("screenshots", screenshots); + putIfPresent(map, "pickleballVersion", pickleballVersion); + return map; + } + } + + public record EmitResult(String investigationId, String reportPath, Path jsonFile, Path htmlFile) { + public Map sparseResult() { + Map result = new LinkedHashMap<>(); + result.put("reportPath", reportPath); + return Map.copyOf(result); + } + } + + public static EmitResult emit(Path projectRoot, Map raw) throws IOException { + Path root = requireProjectRoot(projectRoot); + if (!Files.isDirectory(root)) { + throw new IllegalArgumentException("Consumer project root not found: " + root); + } + Document document = normalize(raw, root); + Path directory = root.resolve(".pickleball") + .resolve(INVESTIGATIONS_DIRECTORY) + .resolve(document.investigationId()); + Files.createDirectories(directory); + + Path jsonFile = directory.resolve("investigation.json"); + Path htmlFile = directory.resolve("report.html"); + Files.writeString(jsonFile, encodePretty(document.toMap()) + "\n", StandardCharsets.UTF_8); + Files.writeString(htmlFile, renderHtml(document, root, directory), StandardCharsets.UTF_8); + + String reportPath = root.relativize(htmlFile).toString().replace('\\', '/'); + return new EmitResult(document.investigationId(), reportPath, jsonFile, htmlFile); + } + + public static Document normalize(Map raw, Path projectRoot) { + if (raw == null || raw.isEmpty()) { + throw new IllegalArgumentException("Investigation JSON must be an object."); + } + Path root = requireProjectRoot(projectRoot); + + String investigationId = requireInvestigationId(firstText(raw, "pkb_investigation_id", "investigationId")); + String createdAt = firstText(raw, "createdAt"); + if (createdAt.isBlank()) createdAt = Instant.now().toString(); + + ScenarioIdentity scenario = scenarioIdentity(raw); + String outcome = normalizeOutcome(firstText(raw, "outcome")); + String cause = firstText(raw, "cause"); + String fix = firstText(raw, "fix"); + if (fix.isBlank()) fix = NOT_FIXED; + + String runId = firstText(raw, "runId", "diagnosticRunId"); + String runIndexPath = projectRelativePath(root, firstText(raw, "runIndexPath")); + if (runIndexPath == null && !runId.isBlank()) { + runIndexPath = projectRelativePath(root, "reports/diagnostic-runs/" + runId + "/run-index.json"); + } + + return new Document( + investigationId, + createdAt, + scenario.name, + scenario.feature, + scenario.scenarioId, + outcome, + cause, + fix, + normalizeCategory(firstText(raw, "category")), + firstText(raw, "failureSignature"), + normalizeFailureSite(raw.get("failureSite")), + runId, + runIndexPath == null ? "" : runIndexPath, + screenshotPaths(root, raw.get("screenshots")), + firstText(raw, "pickleballVersion") + ); + } + + public static String renderHtml(Document document, Path projectRoot) { + Path root = requireProjectRoot(projectRoot); + Path reportDir = root.resolve(".pickleball") + .resolve(INVESTIGATIONS_DIRECTORY) + .resolve(document.investigationId()); + return renderHtml(document, root, reportDir); + } + + static String renderHtml(Document document, Path projectRoot, Path reportDir) { + String title = document.scenarioName().isBlank() + ? document.investigationId() + : document.scenarioName(); + StringBuilder html = new StringBuilder(); + html.append("\n\n"); + html.append("").append(escape(title)).append("\n"); + html.append("\n\n"); + html.append("

").append(escape(title)).append("

\n"); + html.append("

Investigation ").append(escape(document.investigationId())); + html.append(" · ").append(escape(document.outcome())); + if (!document.createdAt().isBlank()) { + html.append(" · ").append(escape(document.createdAt())); + } + html.append("

\n"); + + section(html, "Cause", document.cause()); + section(html, "Fix", document.fix()); + + html.append("

Scenario

\n
\n"); + definition(html, "Name", document.scenarioName()); + definition(html, "Feature", document.feature()); + definition(html, "Scenario id", document.scenarioId()); + definition(html, "Category", document.category()); + definition(html, "Failure signature", document.failureSignature()); + if (document.failureSite() != null) { + definition(html, "Failure site", failureSiteText(document.failureSite())); + } + html.append("
\n"); + + html.append("

Diagnostic run

\n"); + if (document.runId().isBlank() && document.runIndexPath().isBlank()) { + html.append("

No diagnostic run pointer.

\n"); + } else { + html.append("
\n"); + definition(html, "Run id", document.runId()); + if (!document.runIndexPath().isBlank()) { + html.append("
run-index
"); + html.append(pathMarkup(projectRoot, reportDir, document.runIndexPath(), false)); + html.append("
\n"); + } + html.append("
\n"); + } + html.append("
\n"); + + html.append("

Screenshots

\n"); + if (document.screenshots().isEmpty()) { + html.append("

No screenshots selected.

\n"); + } else { + int index = 1; + for (String screenshot : document.screenshots()) { + html.append(pathMarkup(projectRoot, reportDir, screenshot, true)); + if (index < document.screenshots().size()) html.append('\n'); + index++; + } + } + html.append("
\n"); + + if (!document.pickleballVersion().isBlank()) { + html.append("

Pickleball ").append(escape(document.pickleballVersion())).append("

\n"); + } + html.append("\n"); + return html.toString(); + } + + public static Path investigationsRoot(Path pickleballDirectory) { + return pickleballDirectory.resolve(INVESTIGATIONS_DIRECTORY); + } + + public static boolean isInvestigationsPath(Path pickleballDirectory, Path path) { + if (pickleballDirectory == null || path == null) return false; + Path investigations = investigationsRoot(pickleballDirectory).toAbsolutePath().normalize(); + Path resolved = path.toAbsolutePath().normalize(); + return resolved.equals(investigations) || resolved.startsWith(investigations); + } + + private static Path requireProjectRoot(Path projectRoot) { + if (projectRoot == null) { + throw new IllegalArgumentException("Consumer project root is required."); + } + return projectRoot.toAbsolutePath().normalize(); + } + + private static String requireInvestigationId(String value) { + String id = value == null ? "" : value.trim(); + if (!INVESTIGATION_ID.matcher(id).matches()) { + throw new IllegalArgumentException( + "pkb_investigation_id must be a simple directory name " + + "[A-Za-z0-9][A-Za-z0-9._-]* up to 128 characters." + ); + } + return id; + } + + private static ScenarioIdentity scenarioIdentity(Map raw) { + String name = firstText(raw, "scenarioName"); + String feature = firstText(raw, "feature"); + String scenarioId = firstText(raw, "scenarioId"); + Object scenario = raw.get("scenario"); + if (scenario instanceof String text) { + if (name.isBlank()) name = text.trim(); + } else if (scenario instanceof Map map) { + if (name.isBlank()) name = firstText(map, "name", "scenarioName", "title"); + if (feature.isBlank()) feature = firstText(map, "feature", "uri", "featureUri"); + if (scenarioId.isBlank()) scenarioId = firstText(map, "scenarioId", "id"); + } + return new ScenarioIdentity(name, feature, scenarioId); + } + + private static String normalizeOutcome(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT) + .replace('_', '-') + .replace(' ', '-'); + if (normalized.equals(OUTCOME_CAUSE_AND_FIX) || normalized.equals("causeandfix")) { + return OUTCOME_CAUSE_AND_FIX; + } + return OUTCOME_CAUSE_ONLY; + } + + private static String normalizeCategory(String value) { + if (value == null || value.isBlank()) return ""; + String normalized = value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "selector", "gherkin", "java", "data", "other" -> normalized; + default -> value.trim(); + }; + } + + private static Object normalizeFailureSite(Object value) { + if (value == null) return null; + if (value instanceof String text) return text.trim(); + if (value instanceof Number || value instanceof Boolean) return value; + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, child) -> { + if (key instanceof String name && !name.isBlank() && isJsonValue(child)) { + copy.put(name, child); + } + }); + return copy.isEmpty() ? null : Map.copyOf(copy); + } + return String.valueOf(value); + } + + private static List screenshotPaths(Path projectRoot, Object value) { + if (!(value instanceof List entries)) return List.of(); + List paths = new ArrayList<>(); + for (Object entry : entries) { + if (paths.size() >= MAX_SCREENSHOTS) break; + String raw = screenshotEntry(entry); + if (raw == null) continue; + String relative = projectRelativePath(projectRoot, raw); + if (relative != null) paths.add(relative); + } + return List.copyOf(paths); + } + + private static String screenshotEntry(Object entry) { + if (entry instanceof String text) return text; + if (entry instanceof Map map) { + return firstText(map, "path", "file", "src"); + } + return null; + } + + private static String projectRelativePath(Path projectRoot, String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + if (trimmed.isEmpty() || trimmed.length() > MAX_PATH_CHARS) return null; + if (trimmed.contains("\n") || trimmed.contains("\r") || trimmed.startsWith("data:")) return null; + try { + Path path = Path.of(trimmed); + Path resolved = path.isAbsolute() + ? path.normalize() + : projectRoot.resolve(trimmed).normalize(); + if (!resolved.startsWith(projectRoot)) return null; + String relative = projectRoot.relativize(resolved).toString().replace('\\', '/'); + if (relative.isEmpty() || relative.startsWith("../")) return null; + return relative; + } catch (Exception ignored) { + return null; + } + } + + private static String pathMarkup(Path projectRoot, Path reportDir, String relative, boolean image) { + Path target = projectRoot.resolve(relative).normalize(); + boolean present = Files.isRegularFile(target); + if (image) { + if (!present) { + return "

Screenshot missing: " + escape(relative) + "

\n"; + } + return "

\""

\n"; + } + if (!present) { + return "" + escape(relative) + " (missing)"; + } + return "" + + escape(relative) + ""; + } + + private static String relativeHref(Path reportDir, Path target) { + Path from = reportDir.toAbsolutePath().normalize(); + Path to = target.toAbsolutePath().normalize(); + return from.relativize(to).toString().replace('\\', '/'); + } + + private static void section(StringBuilder html, String heading, String body) { + html.append("

").append(escape(heading)).append("

\n"); + html.append("
").append(escape(body)).append("
\n
\n"); + } + + private static void definition(StringBuilder html, String term, String value) { + if (value == null || value.isBlank()) return; + html.append("
").append(escape(term)).append("
") + .append(escape(value)).append("
\n"); + } + + private static String failureSiteText(Object failureSite) { + if (failureSite instanceof Map map) { + StringBuilder text = new StringBuilder(); + map.forEach((key, value) -> { + if (!text.isEmpty()) text.append('\n'); + text.append(key).append(": ").append(value); + }); + return text.toString(); + } + return String.valueOf(failureSite); + } + + private static String firstText(Map map, String... keys) { + if (map == null) return ""; + for (String key : keys) { + Object value = map.get(key); + if (value instanceof String text && !text.isBlank()) return text.trim(); + if (value instanceof Number || value instanceof Boolean) return String.valueOf(value); + } + return ""; + } + + private static void putIfPresent(Map map, String key, String value) { + if (value != null && !value.isBlank()) map.put(key, value); + } + + private static boolean isJsonValue(Object value) { + return value == null + || value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof Map + || value instanceof List; + } + + static String encodePretty(Object value) { + StringBuilder json = new StringBuilder(); + encode(json, value, 0); + return json.toString(); + } + + private static void encode(StringBuilder json, Object value, int indent) { + if (value == null) { + json.append("null"); + return; + } + if (value instanceof String text) { + json.append('"').append(escapeJson(text)).append('"'); + return; + } + if (value instanceof Number || value instanceof Boolean) { + json.append(value); + return; + } + if (value instanceof Map map) { + json.append('{'); + if (map.isEmpty()) { + json.append('}'); + return; + } + json.append('\n'); + int index = 0; + for (Map.Entry entry : map.entrySet()) { + pad(json, indent + 1); + json.append('"').append(escapeJson(String.valueOf(entry.getKey()))).append("\": "); + encode(json, entry.getValue(), indent + 1); + index++; + json.append(index < map.size() ? ",\n" : "\n"); + } + pad(json, indent); + json.append('}'); + return; + } + if (value instanceof List list) { + json.append('['); + if (list.isEmpty()) { + json.append(']'); + return; + } + json.append('\n'); + for (int i = 0; i < list.size(); i++) { + pad(json, indent + 1); + encode(json, list.get(i), indent + 1); + json.append(i + 1 < list.size() ? ",\n" : "\n"); + } + pad(json, indent); + json.append(']'); + return; + } + json.append('"').append(escapeJson(String.valueOf(value))).append('"'); + } + + private static void pad(StringBuilder json, int indent) { + json.append(" ".repeat(Math.max(0, indent))); + } + + static String escape(String value) { + if (value == null || value.isEmpty()) return ""; + StringBuilder escaped = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '&' -> escaped.append("&"); + case '<' -> escaped.append("<"); + case '>' -> escaped.append(">"); + case '"' -> escaped.append("""); + case '\'' -> escaped.append("'"); + default -> escaped.append(ch); + } + } + return escaped.toString(); + } + + private static String escapeJson(String value) { + StringBuilder escaped = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> { + if (ch < 0x20) { + escaped.append(String.format(Locale.ROOT, "\\u%04x", (int) ch)); + } else { + escaped.append(ch); + } + } + } + } + return escaped.toString(); + } + + private record ScenarioIdentity(String name, String feature, String scenarioId) { } +} diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 718c5e0a..9e72c1be 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -103,7 +103,7 @@ Workbench provides: java -jar pickleball-workbench-.jar mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, and sparse diagnostic catalog/run/summary readers through `WorkbenchServices`. Consumer agents use this headless stdio server (`mcp .`), not the Swing GUI. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, and `workbench_investigation_emit` through `WorkbenchServices`. Consumer agents use this headless stdio server (`mcp .`), not the Swing GUI. UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index a43d93c5..95dc025d 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -21,8 +21,10 @@ import tools.dscode.workbench.worker.WorkbenchLiveSession; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; +import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; @@ -376,6 +378,15 @@ public Object diagnosticScenarioSummary(String runId, String scenarioId) { return diagnostics.scenarioSummaryDocument(runId, scenarioId); } + @Override + public Object emitInvestigation(Map investigation) { + try { + return InvestigationHandoff.emit(projectRoot, investigation).sparseResult(); + } catch (IOException failure) { + throw new IllegalStateException("Could not emit investigation handoff.", failure); + } + } + @Override public void close() { lease.detachUi(); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java index 71b8e97b..ebff8984 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -25,6 +25,7 @@ import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Consumer; @@ -149,6 +150,8 @@ ControlBridgeBreakpoint addBreakpoint( Object diagnosticScenarioSummary(String runId, String scenarioId); + Object emitInvestigation(Map investigation); + @Override void close(); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index 22bf3bd0..a8b6fa98 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java @@ -209,6 +209,16 @@ private void register() { "scenarioId", stringProperty("Scenario directory name under that run.") ), "runId", "scenarioId"), args -> services.diagnosticScenarioSummary(text(args, "runId"), text(args, "scenarioId"))); + add("workbench_investigation_emit", + "Write .pickleball/investigations//{investigation.json,report.html} from investigation JSON. Returns the relative report.html path only. Does not copy the diagnostic pack or embed PNG bytes.", + schema(Map.of( + "investigation", Map.of( + "type", "object", + "description", "Investigation JSON object. Source of truth written to investigation.json.", + "additionalProperties", true + ) + ), "investigation"), + args -> services.emitInvestigation(investigationObject(args.get("investigation")))); } private void add( @@ -307,6 +317,28 @@ private static boolean bool(Map args, String name, boolean defau return value instanceof Boolean bool ? bool : defaultValue; } + @SuppressWarnings("unchecked") + private Map investigationObject(Object value) { + if (value == null) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + Object parsed = value; + if (parsed instanceof String text) { + if (text.isBlank()) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + try { + parsed = json.readValue(text, LinkedHashMap.class); + } catch (Exception failure) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + } + if (!(parsed instanceof Map)) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + return json.convertValue(parsed, LinkedHashMap.class); + } + private record ToolBinding( String name, String description, diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java new file mode 100644 index 00000000..e141d8d3 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java @@ -0,0 +1,114 @@ +package tools.dscode.workbench.diagnostics; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.control.protocol.InvestigationHandoff; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InvestigationHandoffTest { + @TempDir + Path project; + + @Test + void htmlEscapesCauseAndCapsScreenshotsAndNotesMissingImages() throws Exception { + Path run = project.resolve("reports/diagnostic-runs/run-1/scenarios/s1/screenshots"); + Files.createDirectories(run); + Path present = run.resolve("frame-1.png"); + Files.write(present, new byte[]{1, 2, 3}); + + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "checkout-217"); + raw.put("createdAt", "2026-08-25T06:00:00Z"); + raw.put("scenario", Map.of( + "name", "Submit
", + "feature", "features/forms.feature", + "scenarioId", "scenario-1" + )); + raw.put("outcome", "CAUSE_ONLY"); + raw.put("cause", "Selector