From 9c255431f23a4fa48a3615b387610b3367f476af Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 20 Aug 2026 01:15:48 -0700 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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