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/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/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 8ba65c6e..5750b2e0 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() @@ -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,99 @@ 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.named('shadowJar').get().archiveFile.get().asFile + File standaloneFile = standaloneWorkbenchJar.get().asFile + def sha256 = { InputStream input -> + def digest = java.security.MessageDigest.getInstance('SHA-256') + byte[] buf = new byte[65536] + int read + while ((read = input.read(buf)) >= 0) { + digest.update(buf, 0, read) + } + digest.digest() + } + + new java.util.jar.JarFile(outerFile).withCloseable { outer -> + def payloadEntries = java.util.Collections.list(outer.entries()).findAll { entry -> + !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[] standaloneHash + standaloneFile.withInputStream { input -> + standaloneHash = sha256(input) + } + byte[] embeddedHash + outer.getInputStream(payloadEntries.first()).withCloseable { input -> + embeddedHash = sha256(input) + } + if (!java.util.Arrays.equals(standaloneHash, embeddedHash)) { + throw new GradleException( + 'Embedded Workbench payload bytes do not match the standalone controller build.' + ) + } + + 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.JarFile(standaloneFile).withCloseable { nested -> + def mainClass = nested.manifest?.mainAttributes?.getValue('Main-Class') + if (mainClass != 'tools.dscode.workbench.WorkbenchApplication') { + throw new GradleException('Embedded Workbench payload has an unexpected Main-Class.') + } + } + } + } +} + +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..3d8d3cb7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ Pickleball extends Cucumber with a dynamic feature-file language while preserving normal Cucumber behavior. The pages below describe the supported authoring model and link to real executable examples in [`maven-consumer-project`](consumer-project.md). -When these docs are materialized from the Maven dependency with `DiagnosticCli export-guidance`, links to `../maven-consumer-project/...` resolve to the version-matched, read-only reference snapshot exported beside the docs. Human readers and AI agents can therefore inspect the same working features, configuration, calls, data, runner, and local test-site examples without checking out the Pickleball source repository. +When these docs are materialized from the Maven dependency with `DiagnosticCli export-guidance`, links to `../maven-consumer-project/...` resolve to the version-matched, read-only reference snapshot exported beside the docs. Human readers can inspect those working features, configuration, calls, data, runner, and local test-site examples without checking out the Pickleball source repository. Consumer AI agents should follow `.pickleball/AGENT-GUIDE.md` first and open a specific guide only when needed. ## Start here @@ -37,7 +37,8 @@ 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. +- [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/README.md b/docs/agent/README.md index 664905ed..efb17fb1 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 @@ -19,7 +20,9 @@ Agent adapters should remain small and point back to the canonical contract rath The nested `/maven-consumer-project/AGENTS.md` is intentionally only a dependency-owned guidance bootstrap. It materializes version-matched guidance and directs the consumer agent to `.pickleball/AGENT-GUIDE.md`. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. -The nested `/maven-consumer-project/README.md` is ordinary sample-project documentation and should not duplicate the AI guidance lifecycle. +The nested `/maven-consumer-project/.github/copilot-instructions.md` is the same one-line bootstrap for IntelliJ Copilot Chat, which reads that file rather than `AGENTS.md`. + +The nested `/maven-consumer-project/README.md` is ordinary sample-project documentation. It may point humans and agents at `AGENTS.md` for guidance export, but should not duplicate the AI guidance lifecycle. `export-guidance .pickleball` is deliberately unconditional before Pickleball work. A successful export writes `.pickleball/GUIDANCE-MANIFEST.json` last, removes obsolete previously managed files, and refreshes current dependency guidance. Git-ignore handling is best effort. If export fails, existing `.pickleball` content is potentially stale. @@ -58,6 +61,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 +98,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 a9016d52..0dd38a0e 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 / 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` | -| 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; `InvestigationHandoff` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Text-Blocks editor toggle | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `tools.dscode.workbench.player`; `LiveEditorView`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | +| Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | 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` | @@ -17,8 +19,8 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/config-files-and-resource-mapping.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements` | `mapping-and-resources.feature`; `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; Data Element features; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md` | | Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_run_profile`, `pkb_configpath` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | -| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; search `export-guidance`, `GUIDANCE-MANIFEST.json` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | -| Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; diagnostic aspects; `DiagnosticCli.java`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | +| Consumer guidance export/reference snapshot | `DiagnosticCli.java`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | +| Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff`; diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; root `AGENTS.md` | | Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation | `nested-and-block-conditionals.feature` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | @@ -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. +`WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. Input fingerprints of Java sources, resources, build files, and dependency artifacts decide skip vs resources-only vs full compile; the output fingerprint in `manifest.json` remains provenance, not a skip key. Sync always passes `-DskipTests`. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. Live Gherkin buffer edits do not require sync. -`WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through 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 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/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is the Text vs Blocks presentation choice for that same buffer; toggling it must not change document text, selection, or playhead id. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use headless MCP (`mcp .`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` so a watcher can join a human GUI session instead of starting a second Workbench; that attach file is not the consumer-agent path for this release. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is Gherkin text or a block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. + +For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index becaa037..fbf3f914 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -9,6 +9,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `build.gradle` - `gradlew` - `gradlew.bat` +- `maven-consumer-project/.github/copilot-instructions.md` - `maven-consumer-project/.mvn/wrapper/maven-wrapper.properties` - `maven-consumer-project/AGENTS.md` - `maven-consumer-project/mvnw` @@ -47,6 +48,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` @@ -362,6 +364,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 +388,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,32 +439,128 @@ 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-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java` + ## Pickleball Workbench module - `gradle/pickleball-published-variant.gradle` - `pickleball-workbench/AGENTS.md` - `pickleball-workbench/build.gradle` - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncMode.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/MappingEditorHost.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WebViewPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java` - `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` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerStatus.java` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html` +- `pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js` - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveEditorViewTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchLiveSessionTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java` @@ -492,6 +592,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/tools/dscode/common/mappings/MappingDataRefactorChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/Diagnostic213CompletionChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/DiagnosticReportingChecks.java` +- `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/util/datetime/BusinessTemporalDeltaChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/util/datetime/BusinessTimePostModifierChecks.java` @@ -561,7 +662,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/ai-diagnostic-reporting-plan.md b/docs/ai-diagnostic-reporting-plan.md index 11b6382e..2d9c4278 100644 --- a/docs/ai-diagnostic-reporting-plan.md +++ b/docs/ai-diagnostic-reporting-plan.md @@ -132,6 +132,7 @@ For a source-only fix, reuse the retained RunVars unchanged and omit `pkb_change ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index 97ef5401..109a9fda 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -243,6 +243,12 @@ Example compact rerun: -Dpkb_changed_variables=pkb_browser ``` +For an agent's bounded confirmation `mvn test` (not `PickleballTests` human defaults of `pretty` / `@all`), include diagnostic evidence controls and keep selection narrow: + +```text +-Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + Lineage metadata is not execution configuration: ```text diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 9160a940..55a833d5 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -2,7 +2,56 @@ This is the canonical AI-agent contract for projects that consume Pickleball as a Maven dependency. -A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. +A consumer project may contain only a short `AGENTS.md` bridge. That bridge can use Pickleball's `DiagnosticCli export-guidance` command to materialize the version-matched guidance embedded in the installed Pickleball dependency. When this file is materialized as `.pickleball/AGENT-GUIDE.md`, supporting documentation is under `.pickleball/docs/` and a curated reference snapshot of Pickleball's executable Maven consumer is under `.pickleball/maven-consumer-project/`. Full `docs/` and the snapshot stay exported for on-demand lookup. Do not dump them into first-read context. + +## Tool chooser + +Use this order. Consumer AI agents for this Pickleball release use headless Workbench MCP (`mcp .`). Do not start the Workbench GUI. + +1. **Live headless MCP** — isolate a failing step in a paused worker. Reuse compilation, rewrite Gherkin in the live buffer, and inspect the page and semantic events in the same browser/Mapping state. +2. **One diagnostic `mvn test`** — after the live loop has isolated the failure, run one bounded confirmation with `pkb_runvars` so an evidence pack is retained. +3. **Emit the human handoff** — write `.pickleball/investigations//` then in chat print only `.pickleball/investigations//report.html`. +4. **Edit real consumer source** — change the project's own features/Java only after the live buffer is right. Explicit Save is what writes a `.feature` file. + +Do not copy consumer features into `.pickleball` as a sandbox. + +### Live isolation loop + +From the consumer project, with Pickleball on the test classpath: + +1. Start the launcher with `mcp .` (not a GUI command). +2. Call `workbench_sync` once. The synchronizer skips Maven/Gradle when Java/build/dependencies are unchanged, and refreshes test resources without a full `test-compile` when only features/config/data changed. +3. `workbench_worker_start` — reuse the compiled live classpath; do not rebuild to start a worker. +4. `workbench_request_control` +5. Isolate with `workbench_execute_step` and/or `workbench_player_replace_document`. +6. Inspect with `workbench_browser_page`, `workbench_element_inspect`, and `workbench_events`. +7. When you need a retained evidence pack, run **one** diagnostic `mvn test` with `pkb_runvars` (below). Read that pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` instead of globbing `reports/diagnostic-runs`. +8. Emit the human handoff with `workbench_investigation_emit` or `DiagnosticCli emit-investigation`. In chat print only `.pickleball/investigations//report.html`. Do not paste the report body, cause/fix essays, or screenshots into the chat panel. + +`workbench_execute_step` failure does not end the worker. Insert, nest, or retry in the same paused browser/Mapping state. Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). + +Worker restart without rebuild already exists (`workbench_worker_restart`). Step Overrides compile worker-side (`workbench_step_override_compile`); they do not require Maven. + +### Generated trees are not the project + +- `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. +- `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. +- `.pickleball/investigations/` is unmanaged consumer-agent output. `export-guidance` leaves it alone. +- `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. + +## First-read + +Keep first-read small. After a successful export: + +1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. +2. Stay in this guide's tool chooser and live loop. +3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. +4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. +5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. + +Do not read `docs/README.md`, the whole `maven-consumer-project/` snapshot, or Workbench GUI pages as first actions. Those remain available on demand. + +The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. ## Generated guidance lifecycle @@ -12,33 +61,20 @@ A successful `export-guidance .pickleball` run: - overwrites the current version's managed guidance files, documentation, and Maven consumer reference snapshot; - writes `.pickleball/GUIDANCE-MANIFEST.json` last, recording the exporting Pickleball version and managed files; -- removes files managed by the previous manifest that are no longer shipped, while leaving unrelated files alone; and +- removes files managed by the previous manifest that are no longer shipped, while leaving unrelated files alone, including `.pickleball/investigations/`; and - best-effort ensures `.pickleball` is ignored by Git, preferring an existing `.gitignore` and then repository-local `.git/info/exclude`. The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat any existing `.pickleball` contents as potentially stale. The manifest records the last completed export; it is not a substitute for rerunning the exporter. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Those leftovers are not authoritative for the downgraded dependency. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. -## First actions - -For Pickleball scenario authoring, configuration, execution, diagnostics, or troubleshooting: - -1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Read this guide before changing Pickleball scenarios or diagnosing a Pickleball run. -3. Use `docs/README.md` as the documentation map. -4. Inspect the consumer project's `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. -5. Use `maven-consumer-project/` as a version-matched read-only reference when a documented syntax/configuration example or working Pickleball consumer structure is useful. -6. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. - -The exported documentation and Maven consumer reference are version-matched to the Pickleball artifact on the consumer's test classpath. Prefer them over instructions or examples copied from another release. - ## Generated Maven consumer reference -`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. +`.pickleball/maven-consumer-project/` is a generated, read-only reference snapshot of the canonical Maven consumer used by Pickleball itself. It preserves repository-relative paths so links from the exported Markdown documentation continue to resolve locally. It is not the consumer project under test and is not a writable sandbox. The snapshot intentionally includes the consumer `pom.xml`, Pickleball runner, local browser/service test server, executable feature files, service-call definitions, configuration/data fixtures, local test-site resources, and the committed shared/local profile and property examples. It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` bridge, internal Java verification classes, and maintainer-only `_local2` files. -Use the snapshot to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not modify or execute files under `.pickleball/maven-consumer-project/` as the consumer project's implementation. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. +Use the snapshot only to answer questions such as how a working feature, profile, property file, service call, configuration resource, browser fixture, or runner is structured. Do not copy, modify, or execute files under `.pickleball/maven-consumer-project/` as the project under test. Make requested changes in the consumer project's own source tree. A later `export-guidance` run may overwrite or remove every managed reference file. `export-guidance` does not copy the consumer's own features into `.pickleball` for testing. ## Scenario authoring and fixes @@ -100,6 +136,22 @@ Never supply `pkb_run_profile` or `pkb_run_profile.` as input. They are When you launch Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative input. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. +For an agent's bounded confirmation `mvn test` (not the human runner defaults), include diagnostic evidence controls and keep the selection narrow. Documented AI defaults: + +```text +pkb_reportingmode=diagnostic +pkb_loglevel=warn +pkb_reportretention=failed +``` + +Use the narrowest `pkb_tags` / `pkb_name` that isolate the failure. Do not add the `pretty` plugin; it is console noise for agents. `pkb_reportretention=failed` keeps dense evidence for failing scenarios and does not retain it for passing ones. + +These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`). Example confirmation after a live-loop isolation: + +```text +mvn test -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + A selected profile or partial `pkb_runvars` input inherits only missing project execution-context RunVars: ```text @@ -139,6 +191,10 @@ Use this escalation order: Stop reading as soon as the current layer answers the investigation. Do not recursively ingest an entire diagnostic run. +From headless Workbench MCP, use `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` for layers 1–3 instead of globbing `reports/diagnostic-runs`. Those tools return sparse JSON only and do not dump `events.jsonl`, traces, or screenshot bytes. + +After isolation and the diagnostic rerun, emit a small human handoff. JSON is the source of truth; HTML is a local render of that JSON plus at most two screenshots linked from the existing diagnostic pack. Do not copy the diagnostic run into `.pickleball/investigations/`. In chat print only the project-relative `report.html` path. + ## Visual evidence rules - Never open a PNG merely to determine whether two screenshots differ. @@ -157,12 +213,13 @@ From a Maven consumer where Pickleball is on the test classpath: ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild ``` -Use `guidance` to print this guide and `export-guidance` to materialize the complete version-matched documentation plus curated Maven consumer reference. Prefer `DiagnosticCli` over constructing Maven classpaths and JShell scripts for routine diagnostic operations. +Use `guidance` to print this guide and `export-guidance` to materialize the complete version-matched documentation plus curated Maven consumer reference. Prefer `DiagnosticCli` over constructing Maven classpaths and JShell scripts for routine diagnostic operations. `emit-investigation` writes `.pickleball/investigations//investigation.json` and `report.html` and prints the relative HTML path. ## Controlled diagnostic reruns @@ -198,7 +255,7 @@ See `docs/ai-run-configuration.md` for the full profile/RunVar contract and `doc ## Pickleball syntax documentation -The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Use `docs/README.md` to select the relevant guide. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: +The exported `docs/` tree is the version-matched reference for all supported Pickleball behavior and syntax. Open a specific guide when the live loop or a diagnostic layer requires that topic; do not start by reading `docs/README.md` as a dump. Its links to the working consumer resolve into the exported `maven-consumer-project/` reference snapshot. In particular: - dynamic Gherkin/action/assertion syntax — `docs/dynamic-steps.md`; - element vocabulary/selectors — `docs/custom-element-definitions.md`; @@ -217,7 +274,7 @@ Do not guess Pickleball syntax when the version-matched guide or executable cons ## Human-readable consumer guidance -Use `docs/consumer-project.md` for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Use `docs/README.md` to navigate the complete bundled documentation. Human readers can open files under `maven-consumer-project/` directly in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples linked from those guides. +Use `docs/consumer-project.md` on demand for the Maven consumer layout, local test site, common tag entry points, diagnostic usage, and example commands. Human readers can start with `docs/README.md` and open files under `maven-consumer-project/` in the IDE to inspect the version-matched working features, configuration, calls, data, runner, and test-site examples. Agents should not treat those as first-read. ## When the core Pickleball repository is also present diff --git a/docs/consumer-project.md b/docs/consumer-project.md index b9cf6e95..35845161 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -26,11 +26,11 @@ Rerun export before Pickleball work even when `.pickleball` already exists. A su Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Those leftovers are not authoritative for the downgraded dependency; prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Human readers can start with `.pickleball/docs/README.md`; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. That guide's tool chooser is the agent path: headless Workbench MCP (`mcp .`), one bounded diagnostic `mvn test`, then edits to the real consumer source. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md`; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot -`export-guidance` also materializes a curated, read-only snapshot of the canonical Pickleball Maven consumer under `.pickleball/maven-consumer-project/`. It is intended for both human readers and AI agents that need concrete working examples in addition to prose documentation. +`export-guidance` also materializes a curated, read-only snapshot of the canonical Pickleball Maven consumer under `.pickleball/maven-consumer-project/`. It is a version-matched **reference** of Pickleball's own example consumer for on-demand lookup, not a sandbox and not the consumer project under test. `export-guidance` does not copy the current consumer's own features into `.pickleball` for testing. The snapshot includes: @@ -42,7 +42,7 @@ The snapshot includes: - static local test-site resources; and - the committed shared/local `profiles*.yaml` and `pickleball*.properties` examples. -It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` bridge, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to edit or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. +It intentionally excludes Maven wrappers, Git/IDE/generated artifacts, the consumer `AGENTS.md` and `.github/copilot-instructions.md` bridges, internal Java verification classes, and maintainer-only `_local2` files. It is reference material, not another consumer project to copy, edit, or run. Make changes in the real consumer project; a future guidance export may replace every managed file in this snapshot. `.pickleball/workbench/live/classes` is a compiled worker overlay, not an editor. ## Purpose @@ -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. Consumer AI agents start the **headless MCP** launcher from the resolved test classpath: + +```bash +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=mcp ." +``` + +```powershell +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." +``` + +Humans who want the Swing player can pass `ui .` instead. Agents for this release should not use the GUI, `ui .`, or `attach.json` as their path. The launcher verifies and extracts the opaque payload beneath `.pickleball/workbench/controller//`, then creates a separate Workbench JVM. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. + Runner defaults include: - glue `com.example.pickleball`; @@ -128,11 +145,19 @@ 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" ``` +Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a bounded confirmation should not reuse those defaults. Use a separate `pkb_runvars` command, for example: + +```bash +mvn test -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +``` + The consumer `pom.xml` also defines Maven profiles such as: ```bash @@ -210,6 +235,7 @@ Do not recursively ingest an entire run. ```text DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] +DiagnosticCli emit-investigation DiagnosticCli rebuild ``` @@ -241,4 +267,4 @@ When evidence supports a bounded rerun: - Port `8765` must be available for the example test server. - Nested README/AGENTS files are minimal adapters; detailed guidance and version-matched reference examples are owned by Pickleball core and exported from the dependency. -Use `docs/README.md` for the complete version-matched Pickleball syntax/documentation map and `maven-consumer-project/` for the corresponding working reference files. +Human readers can use `docs/README.md` for the complete version-matched Pickleball syntax/documentation map and `maven-consumer-project/` for the corresponding working reference files. Agents should not treat those as first-read. diff --git a/docs/diagnostic-reporting.md b/docs/diagnostic-reporting.md index 60e4a50e..f5cabd71 100644 --- a/docs/diagnostic-reporting.md +++ b/docs/diagnostic-reporting.md @@ -119,6 +119,7 @@ Supported command-line operations: ```text DiagnosticCli guidance DiagnosticCli export-guidance [output-directory] +DiagnosticCli emit-investigation DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli rebuild @@ -126,6 +127,36 @@ DiagnosticCli rebuild Prefer `DiagnosticCli` over custom Maven-classpath/JShell workflows for routine comparison and recovery. +`emit-investigation` writes a small human handoff under the consumer project: + +```text +.pickleball/investigations// + investigation.json # source of truth + report.html # one-page local render +``` + +Input is investigation JSON from a file or stdin (`-`) plus the consumer project root. The command prints the project-relative `report.html` path. JSON is the source of truth. HTML renders that JSON plus at most two screenshots *linked* from the existing diagnostic pack; extra screenshot paths are ignored, and a missing image becomes a short note rather than a failed emit. The writer does not copy `reports/diagnostic-runs/` and does not change `pkb_diagnostic_output`. Headless Workbench MCP exposes the same emit as `workbench_investigation_emit` and returns only that relative report path. + +Suggested investigation JSON fields, using existing lineage/diagnostic names where they already exist: + +```text +pkb_investigation_id +createdAt +scenario.name / scenario.feature / scenario.scenarioId +outcome # cause-only | cause-and-fix +cause +fix # text, or "not fixed" +category # selector | gherkin | java | data | other +failureSignature +failureSite +runId +runIndexPath # pointer, not a copy +screenshots # at most two project-relative PNG paths +pickleballVersion +``` + +`export-guidance` does not manage or delete `.pickleball/investigations/`. + ## Outcomes and completion Run outcomes: diff --git a/docs/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-player.md b/docs/pickleball-workbench-player.md new file mode 100644 index 00000000..96578c8b --- /dev/null +++ b/docs/pickleball-workbench-player.md @@ -0,0 +1,141 @@ +# 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, picker, Mapping editor, Terminal, and Diagnostic explorer do not change this boundary. Swing and WebView are presentation adapters over `WorkbenchServices` / `WorkbenchController`. Automatic buffered execution and add-and-continue use the existing `executeStep` worker contract; Workbench does not invent a second Gherkin matcher. WebView JavaScript never executes Gherkin. + +## Feature and scenario picker + +The left rail lists scenarios from `.feature` files in the synchronized consumer project: manifest source roots, conventional `src/test/resources/features`, live merged `features/` resources, and an explicit project-owned `pkb_features` value when present. It does not crawl an unrelated git worktree. + +Name and tag filtering is the primary UI. Type a scenario name and choose a match mode: starts with, contains (default), ends with, or full match. All four modes are case-insensitive and match only the Gherkin `Scenario` / `Scenario Outline` title. Include tags must all be present (AND). Exclude tags drop a scenario if it has any of them (NOT). Tag fields accept values with or without a leading `@` and split on commas and/or whitespace. Empty include/exclude means no tag constraint. Feature, Rule, scenario/outline, and Examples tags are inherited as Cucumber does; Workbench parses them from the same catalog files and does not call Cucumber from the controller JVM. + +Feature-file selection is secondary and collapsed behind **Filter by feature**. With no feature selected, name/tag filters apply to every catalog scenario. Opening that panel still toggles browse mode between Gherkin Feature name and file name + directory path, and click still selects or deselects features. Clicking a scenario loads it into the live editor. The default demo remains loaded until a scenario is chosen. **Save** is the only write-back path. + +## Live Scenario Editor + +The center editor is an embedded HTML/JS block editor in JavaFX `WebView`, or ordinary Gherkin text on the same `LiveScenarioPlayer` buffer. A prominent **Text | Blocks** toggle next to the editor heading switches those views without losing playhead, selection, or document text. Play, Step, and From Here keep using the same `LiveScenarioPlayer`. Blocks are Gherkin text, including `Given` / `When` / `Then`. Nested steps and `IF` / `ELSE` snap as parent/child using leading colons. Clicking a block or line instantly seeks the playhead, like clicking a waveform. The execution cursor is internal to an active run. + +Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text and Blocks is shown as unavailable. + +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario and confirm the copy. The default demo is a small browser scenario against the Maven consumer local test site: + +```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. +- Automatic **Play** / **From Here** send each executable live-buffer line through `executeStep`. While the player is `RUNNING`, that controller call is the single playhead owner: success advances to the next executable line, failure pauses on the failed line. The Swing Play loop then refreshes and schedules the next line without remaking the same mark. An attached agent `execute_step` uses the same follow so the spectator playhead stays aligned. Isolated **Step** pauses first, so it does not move the playhead. Marking an already-consumed step is a no-op, so a leftover UI callback cannot abort playback after a successful worker step. +- Reaching the end while playing changes the player to **Waiting for step** rather than stopping. Typing a new step and pressing **Enter** appends it to the end of the live scenario and executes it as part of the same live run. +- **Ctrl+Enter** updates the selected line in place. The whole-scenario editor also accepts ordinary typing at any line. +- The editor highlights the current playhead line. Successful lines do not retain checkmarks or become locked. + +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 a structured object/property editor rather than a get/put/resolve form. + +It contains: + +1. A **NodeMap** dropdown populated from the actual NodeMaps in the current worker-side `ParsingMap`. +2. A property tree for that NodeMap. Each row edits key, value text, and type (`string`, `numeric`, `boolean`, `object-as-JSON`, `object-as-XML`). + +The dropdown is populated through the existing Mapping snapshot contract using a reserved neutral protocol reference. The worker resolves the reserved reference to a catalog generated from the current `ParsingMap`; the Workbench sees only neutral snapshot data. + +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 in place and choose their type; +- add or rename properties; +- assign JSON or XML object text, which is decoded and sent as a structured `mappingPut` value; +- rename keys through `mappingRestore` of the current object. + +Invalid typed text is not sent to the worker. NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only, preserving the existing restore safety rule. + +## Terminal + +The Terminal tails the worker stdout/stderr files Workbench already creates under `.pickleball/workbench/logs/`. Filter by `TRACE`, `DEBUG`, `INFO`, `WARNING`, or `ERROR`. Logs continue as the playhead moves. This is not MCP stdout and is not a fabricated Workbench-only activity dump. Unmarked worker output is shown at `INFO`. + +## Diagnostic Log Explorer + +The explorer is a rewind/play/focus timeline of retained Pickleball diagnostic runs. Screenshot frames are shown with the Gherkin step that was running when they were taken. Denser layers follow the repository evidence order and only open when the retained files exist. If `reports/diagnostic-runs/run-catalog.json` is missing, the panel stays empty and says so. + +## Watched-agent control lease + +The live player is a collaborative testing space, not a second editor. Swing and an attached agent share one `LiveScenarioPlayer` in the Workbench controller. + +- A human can work alone: edit/play the live buffer, then **Save** asks before copying into the original scenario in the original `.feature` file. +- An agent attaches to the running UI through `.pickleball/workbench/attach.json` (localhost JSON tools over the same `WorkbenchServices`). It must not start a second Workbench JVM or worker. +- After `workbench_request_control`, Swing play/edit/picker/filter/editor-view/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. +- `workbench_request_save` is the only original-feature write path for the agent. With the UI attached it blocks on Allow/Deny. Deny writes nothing. Headless stdio MCP may hold the lease without a banner; Save is still an explicit tool. + +See [pickleball-workbench.md](pickleball-workbench.md) for attach discovery, tool names, and stdout rules. + +## 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 picker name/tag/feature filtering (including Feature-level tag inheritance), block buffer ↔ player model, Text | Blocks view toggling without changing document text or playhead id, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, leftover Play-loop playhead marks after `executeStep`, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. + +Workbench changes should continue to use the repository's focused validation policy: + +```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/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index b0e888bc..d376c7e2 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -1,22 +1,28 @@ # 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 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 -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, and the thin Swing UI. +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. +MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. A visible UI keeps one Workbench JVM and one consumer worker. An AI agent attaches to that live session through a localhost HTTP JSON facade; it must not start a second Workbench or a second worker. The canonical worker bridge environment is: @@ -29,12 +35,57 @@ 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: + +```text +META-INF/pickleball/workbench/pickleball-workbench.jar +``` -Build the executable companion: +Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version. + +Consumer AI agents use headless MCP: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=mcp ." +``` ```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=mcp ." +``` + +Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Agents for this release should not use the GUI, `ui .`, or `.pickleball/workbench/attach.json` as their path; see `.pickleball/AGENT-GUIDE.md`. + +Gradle consumers can expose the same dependency-owned launcher without resolving a cache path or adding a Workbench dependency: + +```groovy +tasks.register('pickleballWorkbench', JavaExec) { + classpath = sourceSets.test.runtimeClasspath + mainClass = 'tools.dscode.launcher.PickleballWorkbenchLauncher' + args 'mcp', projectDir.absolutePath // consumer agents; pass 'ui' for the Swing player +} +``` + +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 streams the nested payload (it does not load the controller JAR into a byte array), hashes SHA-256 while copying, and rejects payloads larger than 512 MiB. OpenJFX WebView natives make the executable larger than a plain Java controller. It extracts atomically to: + +```text +.pickleball/workbench/controller//pickleball-workbench.jar +``` + +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,31 +94,155 @@ 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 from repository output: ```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" ``` -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. +Synchronization uses the selected project wrapper to establish compiled output and the effective test runtime classpath. It keeps `-DskipTests` (Surefire never runs during sync). Input fingerprints of Java sources, resources, build files, and dependency artifacts decide how much of the wrapper to run: + +- **Skip** when those inputs match the last recorded input fingerprints and the live snapshot is present. The output `fingerprint` in `manifest.json` is provenance over merged classes plus dependency bytes; it is not the skip key. +- **Resources-only** when only feature/config/data (test resources) changed: Maven `process-resources` / `process-test-resources`, or Gradle `processResources` / `processTestResources`, without `test-compile` / `testClasses`. If compiled outputs were cleaned, sync escalates to a full compile so live classes are not wiped. +- **Full** when Java sources, the build descriptor, or dependency artifact bytes changed, or when no prior snapshot exists. + +Live Gherkin buffer edits never require sync and never write the original `.feature` until explicit Save. Worker restart without rebuild already exists. Step Overrides stay worker-side compile. + +`.pickleball/workbench/base/classes` is provenance only; the worker runs against the merged `.pickleball/workbench/live/classes` state plus captured external dependencies. Do not use `live/classes` as an editor. + +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 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. + +### Player-style layout + +The primary workspace is an interactive Gherkin player with a project feature picker: + +```text ++-------------------------------------------------------------------------+ +| Scenarios | Project / readiness Play Pause Stop Player status | ++-----------+--------------------------+----------------------------------+ +| Name + | LIVE GHERKIN EDITOR | Mapping | Terminal | Diagnostic | +| match mode| [Text | Blocks] | WebView tree / typed values | +| tags AND | playhead on same buffer | worker log / retained-run frames | +| tags NOT | | | +| scenario | | | +| list | | | +| (feature | | | +| filter | | | +| hidden) | | | ++-----------+--------------------------+ | +| | Step Editor [Step] [From | | +| | Here] [ live command ] | | ++-----------+--------------------------+----------------------------------+ +| Footer activity | ++-------------------------------------------------------------------------+ +``` + +The left rail is a scenario filter, not a feature-file browser. Primary controls are scenario name (starts with / contains / ends with / full match; default contains; all four are case-insensitive against the Gherkin Scenario / Scenario Outline title), tags the scenario must have (AND), and tags it must not have (NOT). Include/exclude fields accept any number of tags, with or without a leading `@`, split on commas and/or whitespace. Empty include/exclude means no tag constraint. Feature-level tags, optional Rule tags, the scenario/outline's own tags, and Examples tags on an outline are inherited the same way Cucumber does; Workbench parses those tags from the catalog `.feature` files and does not call Cucumber. Feature-file selection is collapsed behind **Filter by feature** (Gherkin Feature name vs file path lives in that panel). Default: no feature filter, so name/tag apply to every catalog scenario in the synchronized project. Clicking a result still loads that scenario into the live session buffer. Workbench does not write `.feature` files unless you use the explicit **Save** control. + +The center editor shows the same `LiveScenarioPlayer` buffer as ordinary Gherkin text or as the embedded HTML/JS block editor hosted in JavaFX `WebView` (`JFXPanel`). A prominent **Text | Blocks** toggle next to the editor heading switches views without losing playhead, selection, or document text. Blocks are Gherkin text — including `Given` / `When` / `Then` — not a second language compiled to Gherkin. Nested steps and `IF` / `ELSE` blocks snap as parent/child using Pickleball's leading-colon grammar. The play header is unchanged: click-to-seek, global **Play** from the first step in a fresh worker context, **Step** = isolated `executeStep`, **From Here** = selected/playhead through the rest, stay in play at end, Enter append-and-run. JavaScript never executes Gherkin. If JavaFX cannot start, Text is already the fallback and Blocks stays disabled/unavailable. + +The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level lifecycle controls stay under **Session**. Existing investigation tools stay under **Tools > Advanced Controls**. + +### 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 as editable Gherkin text; +- selected line; +- playhead (the user-visible needle); +- player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. + +The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live. **Save** is confirmation-gated: it copies the live scenario into the originating `.feature` file and scenario only after Allow. The Workbench-owned demo has no save path. Workbench never writes `.feature` files on picker load or on Deny. + +The playhead behaves like an audio-player needle: + +- clicking a scenario line instantly seeks the playhead to that line; +- while a run is active, `executeStep` advances the playhead once on success (or pauses it on failure); the UI Play loop continues from that new next step without remaking the same mark; +- **Pause** and **Stop** do not claim to rewind browser, Mapping, service, or other worker side effects. + +Global **Play** always starts a fresh interactive scenario context and runs from the first executable step, even if the playhead is elsewhere. Fresh **Play** / **From Here** runs restart the consumer worker so prior side effects do not masquerade as the start of a scenario. + +### Step Editor play actions + +The Step Editor exposes two distinct play actions on the same `WorkbenchServices.executeStep` seam used by MCP: + +```text +▶ 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 +``` + +**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. + +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. + +### Stay in play / add-and-continue + +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. + +### Default demo scenario + +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: + +```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` 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. + +### Mapping tab + +There is no GUI-defined `Current Scope` concept and no hard-coded NodeMap names. The Mapping tab is a structured property tree populated from the actual NodeMaps in the current worker-side `ParsingMap`. Each property is edited in place: key, value text, and a type dropdown (`string`, `numeric`, `boolean`, `object-as-JSON`, `object-as-XML`). Typed writes go through the existing `mappingPut` service; key renames and whole-object replacement use `mappingRestore`. The GUI must not recreate inheritance rules or keep a second Mapping store. + +NodeMap implementations that are not exact ordinary `NodeMap` instances remain inspection-only. MCP continues to support arbitrary JSON-compatible Mapping values through the shared service methods. + +### WebView packaging + +JDK 21 does not ship a modern browser panel. Workbench embeds OpenJFX `WebView` through `JFXPanel` for the Gherkin editor, Mapping tree, and Diagnostic explorer. That choice stays Workbench-only: Maven-central JavaFX modules are shaded into the controller executable, including platform natives under `javafx-natives//`. JCEF was not used because Chromium natives are harder to keep isolation-clean and do not package as ordinary Workbench dependencies. If JavaFX cannot start, the live editor stays on the existing in-place text buffer on the same `LiveScenarioPlayer` model; the **Text | Blocks** toggle remains visible and Blocks is disabled so the fallback is honest. + +### Terminal and Diagnostic Log Explorer + +The Terminal tab presents the existing consumer-worker stdout/stderr capture files under `.pickleball/workbench/logs/` as a scenario-run log. A dropdown filters `TRACE`, `DEBUG`, `INFO`, `WARNING`, and `ERROR`. Lines continue as the playhead moves because the panel tails those files and also records `executeStep` / Mapping results. Workbench does not redirect MCP stdout. If a worker log line has no printed level, it is shown at `INFO` rather than invented. Structured Pickleball logger output is used when present; there is no second log fabricator. -The UI provides: +The Diagnostic Log Explorer is a WebView timeline over Pickleball's retained diagnostic artifacts (`reports/diagnostic-runs`). It follows the existing evidence escalation order and does not create a competing store or fake retained-run data: + +1. `run-catalog.json` +2. selected `run-index.json` / `clusters.json` +3. scenario `summary.json` +4. relevant `events.jsonl` +5. existing comparison/fingerprint metadata +6. PNG frames only when a retained screenshot exists, shown with the Gherkin step text that was running when taken +7. raw trace only when structured evidence is insufficient + +If the consumer project has no `run-catalog.json`, the explorer says so and stays empty. + +### 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,22 +251,72 @@ 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, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. The Live Scenario Editor is a session-scoped Gherkin player/editor, not a workspace file explorer and not an automatic writer of consumer `.feature` files. + +### Watched AI-agent control lease + +Workbench owns one control lease for the live session. Swing and MCP/HTTP adapters share it; the lease is not Swing-only state. + +- Holder is `HUMAN` when the UI is up, or `AGENT` after an attached agent requests control. +- The snapshot also carries the agent display name, `currentAction` banner text, and at most one pending permission request. + +While the human holds the lease, Swing play/edit/mapping/save/worker controls stay enabled. Agent mutating calls fail clearly until `workbench_request_control`. + +While an agent holds the lease, the human can watch the same window. Play, edit, picker/filter, editor view toggle, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. + +The agent should update `currentAction` as it works. Playhead, Mapping, Terminal, and screenshots follow because the agent uses the same `LiveScenarioPlayer` / worker as the UI. Testing the live scenario (`executeStep`, play, Mapping reads, evidence) is allowed on the agent lease. Copying the live scenario into the original `.feature` is not; that goes through `workbench_request_save` and waits for Allow/Deny in the Swing banner. + +Human **Save** uses the same service. After a picker scenario was loaded, Swing asks: copy these live steps into file X / scenario Y? Deny writes nothing. The demo buffer stays unsavable. + +### Attaching an agent to a visible UI + +The Swing UI is a human player. Consumer AI agents for this release should start headless `mcp .` instead of attaching to a GUI. + +UI mode cannot share process stdout with stdio MCP. Starting `mcp` while the UI is already running would be a second Workbench JVM. Instead, `ui` starts a 127.0.0.1-only JSON attach endpoint over the same `WorkbenchServices` / `WorkbenchMcpTools` methods and writes disposable discovery state: + +```text +.pickleball/workbench/attach.json +``` + +Example: + +```json +{ + "url": "http://127.0.0.1:51234", + "token": "hex-session-token", + "pid": 12345, + "project": "/path/to/maven-consumer-project", + "mode": "ui-attach", + "bind": "127.0.0.1" +} +``` + +A Copilot or other MCP-style client finds that file in the consumer project, then: + +1. `GET {url}/health` — liveness, no token. +2. `GET {url}/lease` and `GET {url}/player` — `Authorization: Bearer ` or `X-Workbench-Token`. +3. `POST {url}/tools/workbench_request_control` with `{"agentName":"Copilot"}`. +4. Use the existing live tools (`workbench_execute_step`, Mapping, evidence, worker) while holding the lease, and `workbench_set_current_action` so the human can watch. +5. `POST {url}/tools/workbench_request_save` to ask to copy the live scenario into the original feature. The call blocks until the human clicks Allow or Deny, or Take control. + +Headless `java -jar pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That is the consumer-agent path. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. + +A human-watched UI session is optional and separate. From `maven-consumer-project`, a person may start `ui .` and then a watcher can join `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session, and do not treat that attach file as the default agent path. ## MCP stdio -Start the lightweight non-Spring MCP server for a synchronized consumer project: +Start the lightweight non-Spring MCP server for a consumer project. This is the consumer-agent path: ```powershell java -jar $workbenchJar mcp ".\maven-consumer-project" ``` +Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."`. Do not document or use the Swing GUI as the agent path. + The server uses the official Java MCP SDK core and stdio transport with the Jackson 2 JSON adapter. MCP dependencies are Workbench-only and are shaded into the executable companion. Workbench deliberately does not use Spring Boot, Spring Framework, Spring AI, WebMVC, or Tomcat. ### Stdout contract @@ -125,9 +350,18 @@ workbench_worker_stop workbench_worker_status ``` -Live runtime and Mapping: +`workbench_sync` uses the skip / resources-only / full rules above. Live buffer edits do not require it. + +Live runtime, Mapping, and watched-agent control: ```text +workbench_request_control +workbench_release_control +workbench_set_current_action +workbench_control_lease +workbench_player_state +workbench_player_replace_document +workbench_request_save workbench_execute_step workbench_mapping_get workbench_mapping_put @@ -164,8 +398,24 @@ workbench_step_override_remove workbench_step_override_clear ``` +Sparse diagnostic readers (do not glob `reports/diagnostic-runs`; these return JSON only and do not dump events, traces, or PNG bytes): + +```text +workbench_diagnostic_catalog +workbench_diagnostic_run +workbench_diagnostic_summary +``` + +Human investigation handoff (writes `.pickleball/investigations//` and returns the relative `report.html` path only): + +```text +workbench_investigation_emit +``` + `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. +Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. + Controller/runtime failures are returned as MCP tool results with `isError=true`. They are not printed as arbitrary protocol output. ## Scope boundary @@ -174,14 +424,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 SHA-256 matches the standalone controller JAR; +- Workbench/MCP entries are not flattened into the outer runtime namespace; and +- the nested payload has the expected Workbench `Main-Class`. Report the executable size and resolved MCP SDK artifacts with: @@ -196,27 +450,52 @@ 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 live player/editor ```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. - -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)`. +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 a scenario name/tag filter rail (feature-file filter collapsed), the Live Gherkin Editor with a **Text | Blocks** toggle and compact Step Editor in the center, and exactly Mapping / Terminal / Diagnostic Log Explorer on the right. +2. Confirm the default buffer is the Workbench demo scenario and includes `navigate to: URL.home` plus a click on the local test site when no picker scenario is selected. +3. Filter scenarios by name using contains (default) and the other match modes; confirm matching is case-insensitive and applies to the Scenario / Scenario Outline title. +4. Filter with include tags (AND) and exclude tags (NOT), with and without `@`, and confirm Feature-level tags apply to scenarios in that feature. +5. Confirm **Filter by feature** is collapsed by default and that name/tag filters then apply to every catalog scenario. Opening it still supports multi-select and Feature name vs file path. +6. Click a filtered scenario and verify it loads into the live buffer. Switch **Text | Blocks** and verify playhead, selection, and document text are unchanged. If WebView is unavailable, Blocks is disabled and Text remains the editor. +7. Click different scenario blocks/lines and verify the playhead highlight moves immediately to the clicked step. +8. Edit previously typed or previously executed Gherkin directly in the Live Scenario Editor and verify the line text updates in place. +9. Press global **Play** after seeking the playhead to a later step and verify execution still starts from the first executable step in a fresh worker context. +10. Use **From Here** on a later executable step and verify playback starts there and continues through the rest of the buffer. +11. Use **Step** in the Step Editor and verify isolated `executeStep` execution that leaves automatic playback paused. +12. Let a run reach the end and verify the player stays in **Waiting for step**. Type a new step and press Enter; the step is appended and executed without dropping out of play. +13. Treat **Pause** / **Stop** as presentation/control of automatic advancement only; they do not rewind browser or service side effects. +14. Verify Mapping has no `Current Scope` control and no hard-coded NodeMap choices. Top-level properties come from the worker ParsingMap and accept typed in-place edits. +15. Verify Terminal filters worker log files by level and continues as steps run, without writing to MCP stdout. +16. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. +17. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. +18. Verify blocking runtime actions leave the Swing UI responsive. +19. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. +20. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/picker/filter/editor-view/mapping/save/worker controls. **Take control** remains enabled. +21. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. +22. The default demo remains unsavable for both human Save and agent `workbench_request_save`. ## Regression +For the player state model and Swing/controller behavior: + +```powershell +.\gradlew.bat :pickleball-workbench:test +``` + +For protocol, dependency, nested-artifact, and process-boundary checks: + +```powershell +.\gradlew.bat verifyStrictControllerIsolation +``` + For shared controller/MCP behavior: ```powershell @@ -226,9 +505,18 @@ 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" 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/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/maven-consumer-project/.github/copilot-instructions.md b/maven-consumer-project/.github/copilot-instructions.md new file mode 100644 index 00000000..e303a3a3 --- /dev/null +++ b/maven-consumer-project/.github/copilot-instructions.md @@ -0,0 +1 @@ +Before any Pickleball work, run `mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.common.reporting.diagnostic.DiagnosticCli" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. diff --git a/maven-consumer-project/README.md b/maven-consumer-project/README.md index 1046cff6..f0fd1bfb 100644 --- a/maven-consumer-project/README.md +++ b/maven-consumer-project/README.md @@ -2,6 +2,8 @@ This is a standalone Maven example project that consumes Pickleball as a test-scoped dependency and exercises browser and service behavior against its local test site. +Before Pickleball work, export guidance and see `AGENTS.md`. + ## Run ```bash diff --git a/maven-consumer-project/mvnw b/maven-consumer-project/mvnw old mode 100644 new mode 100755 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/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/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/InternalFrameworkTestSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java index 9f0483d7..a7d16c46 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java @@ -12,6 +12,7 @@ import tools.dscode.common.mappings.MappingDataRefactorChecks; import tools.dscode.common.reporting.diagnostic.Diagnostic213CompletionChecks; import tools.dscode.common.reporting.diagnostic.DiagnosticReportingChecks; +import tools.dscode.common.reporting.diagnostic.InvestigationHandoffChecks; import tools.dscode.common.reporting.diagnostic.PickleballGuidanceChecks; import tools.dscode.common.reporting.diagnostic.ReportRetentionPolicy; import tools.dscode.common.util.datetime.BusinessTemporalDeltaChecks; @@ -55,7 +56,8 @@ public static void runDiagnosticReportingJavaTests() { runAndAssert( DiagnosticReportingChecks.class, Diagnostic213CompletionChecks.class, - PickleballGuidanceChecks.class + PickleballGuidanceChecks.class, + InvestigationHandoffChecks.class ); } finally { ReportRetentionPolicy.clearThreadOverride(); diff --git a/maven-consumer-project/src/test/java/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/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java new file mode 100644 index 00000000..9ff5a12d --- /dev/null +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/InvestigationHandoffChecks.java @@ -0,0 +1,146 @@ +package tools.dscode.common.reporting.diagnostic; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import tools.dscode.control.protocol.InvestigationHandoff; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class InvestigationHandoffChecks { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void jsonToHtmlEscapesTextCapsScreenshotsAndNotesMissingImages() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-html"); + try { + Path shots = project.resolve("reports/diagnostic-runs/run-1/scenarios/s1/screenshots"); + Files.createDirectories(shots); + Files.write(shots.resolve("ok.png"), new byte[]{1, 2, 3}); + + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "form-1"); + raw.put("scenario", Map.of("name", "Click ")); + raw.put("cause", "Looked for & clicked the wrong one."); + raw.put("screenshots", List.of( + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/ok.png", + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/gone.png", + "reports/diagnostic-runs/run-1/scenarios/s1/screenshots/third.png" + )); + + InvestigationHandoff.Document document = InvestigationHandoff.normalize(raw, project); + assertEquals(2, document.screenshots().size()); + String html = InvestigationHandoff.renderHtml(document, project); + assertTrue(html.contains("Looked for <input> & clicked the wrong one.")); + assertTrue(html.contains("Click <Go>")); + assertTrue(html.contains("ok.png")); + assertTrue(html.contains("Screenshot missing: reports/diagnostic-runs/run-1/scenarios/s1/screenshots/gone.png")); + assertFalse(html.contains("third.png")); + assertFalse(html.contains("")); + } finally { + deleteTree(project); + } + } + + @Test + void diagnosticCliEmitWritesHandoffPairAndDoesNotCopyTheDiagnosticPack() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-cli"); + try { + Path run = project.resolve("reports/diagnostic-runs/run-9/scenarios/s1/screenshots"); + Files.createDirectories(run); + Path png = run.resolve("frame.png"); + Files.write(png, new byte[]{8, 8, 8}); + Path index = project.resolve("reports/diagnostic-runs/run-9/run-index.json"); + Files.writeString(index, "{\"runId\":\"run-9\"}", StandardCharsets.UTF_8); + + Path input = project.resolve("handoff.json"); + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "cli-9"); + raw.put("cause", "The catalog button was stale."); + raw.put("outcome", "cause-only"); + raw.put("runId", "run-9"); + raw.put("failureSignature", "stale-element"); + raw.put("screenshots", List.of( + "reports/diagnostic-runs/run-9/scenarios/s1/screenshots/frame.png" + )); + JSON.writeValue(input.toFile(), raw); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int status = DiagnosticCli.run( + new String[]{"emit-investigation", input.toString(), project.toString()}, + new PrintStream(output, true, StandardCharsets.UTF_8), + System.err + ); + assertEquals(0, status); + String reportPath = output.toString(StandardCharsets.UTF_8).trim(); + assertEquals(".pickleball/investigations/cli-9/report.html", reportPath); + + Path jsonFile = project.resolve(".pickleball/investigations/cli-9/investigation.json"); + Path htmlFile = project.resolve(".pickleball/investigations/cli-9/report.html"); + assertTrue(Files.isRegularFile(jsonFile)); + assertTrue(Files.isRegularFile(htmlFile)); + assertTrue(Files.isRegularFile(png)); + assertTrue(Files.isRegularFile(index)); + + String json = Files.readString(jsonFile, StandardCharsets.UTF_8); + assertTrue(json.contains("\"pkb_investigation_id\" : \"cli-9\"") + || json.contains("\"pkb_investigation_id\": \"cli-9\"")); + assertTrue(json.contains("stale-element")); + assertFalse(json.contains("iVBORw0KGgo")); + + try (var paths = Files.walk(project.resolve(".pickleball/investigations"))) { + List files = paths.filter(Files::isRegularFile).toList(); + assertEquals(2, files.size()); + } + + String html = Files.readString(htmlFile, StandardCharsets.UTF_8); + assertTrue(html.contains("../../../reports/diagnostic-runs/run-9/scenarios/s1/screenshots/frame.png")); + assertTrue(html.contains("not fixed")); + } finally { + deleteTree(project); + } + } + + @Test + void diagnosticCliReadsInvestigationJsonFromStdin() throws Exception { + Path project = Files.createTempDirectory("pickleball-investigation-stdin"); + try { + String json = """ + {"pkb_investigation_id":"stdin-1","cause":"A mapping key was wrong."} + """; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int status = DiagnosticCli.run( + new String[]{"emit-investigation", "-", project.toString()}, + new PrintStream(output, true, StandardCharsets.UTF_8), + System.err, + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)) + ); + assertEquals(0, status); + assertEquals(".pickleball/investigations/stdin-1/report.html", output.toString(StandardCharsets.UTF_8).trim()); + assertTrue(Files.isRegularFile(project.resolve(".pickleball/investigations/stdin-1/investigation.json"))); + } finally { + deleteTree(project); + } + } + + private static void deleteTree(Path root) throws Exception { + if (root == null || !Files.exists(root)) return; + try (var paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java index c0bc3c08..65c6ba1f 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java @@ -40,8 +40,19 @@ void dependencyPrintsCanonicalAgentGuide() { assertTrue(guide.contains("GUIDANCE-MANIFEST.json")); assertTrue(guide.contains("keep terminal logging minimal")); assertTrue(guide.contains("older Pickleball release whose exporter predates the manifest lifecycle")); - assertTrue(guide.contains("Generated Maven consumer reference")); - assertTrue(guide.contains("maven-consumer-project/")); + assertTrue(guide.contains("Generated Maven consumer reference")); + assertTrue(guide.contains("maven-consumer-project/")); + assertTrue(guide.contains("mcp .")); + assertTrue(guide.contains("workbench_sync")); + assertTrue(guide.contains("workbench_execute_step")); + assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("workbench_investigation_emit")); + assertTrue(guide.contains("emit-investigation")); + assertTrue(guide.contains("pkb_reportingmode=diagnostic")); + assertTrue(guide.contains("pkb_reportretention=failed")); + String chooser = guide.substring(0, guide.indexOf("Generated guidance lifecycle")); + assertFalse(chooser.contains("attach.json")); + assertFalse(chooser.contains("ui .")); } @Test @@ -112,6 +123,7 @@ void dependencyExportsVersionMatchedGuidanceAndManifest() throws Exception { )); assertTrue(managedFiles.stream().noneMatch(path -> path.contains("_local2"))); assertFalse(managedFiles.contains("maven-consumer-project/AGENTS.md")); + assertFalse(managedFiles.contains("maven-consumer-project/.github/copilot-instructions.md")); assertFalse(managedFiles.contains("maven-consumer-project/mvnw")); assertFalse(managedFiles.contains( "maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java" @@ -124,6 +136,11 @@ void dependencyExportsVersionMatchedGuidanceAndManifest() throws Exception { assertTrue(guide.contains("keep terminal logging minimal")); assertTrue(guide.contains("older Pickleball release whose exporter predates the manifest lifecycle")); assertTrue(guide.contains("read-only reference snapshot")); + assertTrue(guide.contains("mcp .")); + assertTrue(guide.contains("workbench_diagnostic_catalog")); + assertTrue(guide.contains("workbench_investigation_emit")); + assertTrue(guide.contains("pkb_reportretention=failed")); + assertTrue(guide.contains("Do not copy, modify, or execute files")); String consumerProject = Files.readString(root.resolve("docs/consumer-project.md")); assertTrue(consumerProject.contains("keep console verbosity low")); @@ -200,6 +217,49 @@ void exportRemovesObsoleteManagedFilesButPreservesUnmanagedFiles() throws Except } } + @Test + void exportDoesNotDeleteUnmanagedInvestigationsDirectory() throws Exception { + Path consumer = Files.createTempDirectory("pickleball-guidance-investigations"); + Path root = consumer.resolve(".pickleball"); + try { + assertEquals(0, DiagnosticCli.run( + new String[]{"export-guidance", root.toString()}, + System.out, + System.err + )); + + Path investigation = root.resolve("investigations/keep-me/investigation.json"); + Path report = root.resolve("investigations/keep-me/report.html"); + Path empty = root.resolve("investigations/empty"); + Files.createDirectories(investigation.getParent()); + Files.createDirectories(empty); + Files.writeString(investigation, "{\"pkb_investigation_id\":\"keep-me\"}", StandardCharsets.UTF_8); + Files.writeString(report, "keep", StandardCharsets.UTF_8); + + Map manifest = readManifest(root); + List managedFiles = new ArrayList<>(asStringList(manifest.get("files"))); + managedFiles.add("investigations/keep-me/investigation.json"); + managedFiles.add("investigations/keep-me/report.html"); + manifest.put("files", managedFiles); + JSON.writeValue(root.resolve("GUIDANCE-MANIFEST.json").toFile(), manifest); + + assertEquals(0, DiagnosticCli.run( + new String[]{"export-guidance", root.toString()}, + System.out, + System.err + )); + + assertTrue(Files.isRegularFile(investigation)); + assertTrue(Files.isRegularFile(report)); + assertTrue(Files.isDirectory(empty)); + assertEquals("{\"pkb_investigation_id\":\"keep-me\"}", Files.readString(investigation, StandardCharsets.UTF_8)); + Map next = readManifest(root); + assertFalse(asStringList(next.get("files")).stream().anyMatch(path -> path.contains("investigations/"))); + } finally { + deleteTree(consumer); + } + } + @Test void exportAddsPickleballToExistingConsumerGitignoreWithoutDuplicates() throws Exception { Path consumer = Files.createTempDirectory("pickleball-guidance-ignore"); diff --git a/maven-consumer-project/src/test/resources/features/control-bridge.feature b/maven-consumer-project/src/test/resources/features/control-bridge.feature index 3a45e2ec..0e1eb3a0 100644 --- a/maven-consumer-project/src/test/resources/features/control-bridge.feature +++ b/maven-consumer-project/src/test/resources/features/control-bridge.feature @@ -1,7 +1,10 @@ @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 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/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/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-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..d26970d8 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java @@ -0,0 +1,43 @@ +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"; + + /* + * 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"; + 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-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java new file mode 100644 index 00000000..cbd540e7 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java @@ -0,0 +1,527 @@ +package tools.dscode.control.protocol; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Shared JDK-only writer for consumer-agent investigation handoffs. + * + *

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

+ * + *

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

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

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

\n"); + html.append("

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

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

Scenario

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

Diagnostic run

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

No diagnostic run pointer.

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

Screenshots

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

No screenshots selected.

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

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

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

Screenshot missing: " + escape(relative) + "

\n"; + } + return "

\""

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

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

\n"); + html.append("
").append(escape(body)).append("
\n
\n"); + } + + private static void definition(StringBuilder html, String term, String value) { + if (value == null || value.isBlank()) return; + html.append("
").append(escape(term)).append("
") + .append(escape(value)).append("
\n"); + } + + private static String failureSiteText(Object failureSite) { + if (failureSite instanceof Map map) { + StringBuilder text = new StringBuilder(); + map.forEach((key, value) -> { + if (!text.isEmpty()) text.append('\n'); + text.append(key).append(": ").append(value); + }); + return text.toString(); + } + return String.valueOf(failureSite); + } + + private static String firstText(Map map, String... keys) { + if (map == null) return ""; + for (String key : keys) { + Object value = map.get(key); + if (value instanceof String text && !text.isBlank()) return text.trim(); + if (value instanceof Number || value instanceof Boolean) return String.valueOf(value); + } + return ""; + } + + private static void putIfPresent(Map map, String key, String value) { + if (value != null && !value.isBlank()) map.put(key, value); + } + + private static boolean isJsonValue(Object value) { + return value == null + || value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof Map + || value instanceof List; + } + + static String encodePretty(Object value) { + StringBuilder json = new StringBuilder(); + encode(json, value, 0); + return json.toString(); + } + + private static void encode(StringBuilder json, Object value, int indent) { + if (value == null) { + json.append("null"); + return; + } + if (value instanceof String text) { + json.append('"').append(escapeJson(text)).append('"'); + return; + } + if (value instanceof Number || value instanceof Boolean) { + json.append(value); + return; + } + if (value instanceof Map map) { + json.append('{'); + if (map.isEmpty()) { + json.append('}'); + return; + } + json.append('\n'); + int index = 0; + for (Map.Entry entry : map.entrySet()) { + pad(json, indent + 1); + json.append('"').append(escapeJson(String.valueOf(entry.getKey()))).append("\": "); + encode(json, entry.getValue(), indent + 1); + index++; + json.append(index < map.size() ? ",\n" : "\n"); + } + pad(json, indent); + json.append('}'); + return; + } + if (value instanceof List list) { + json.append('['); + if (list.isEmpty()) { + json.append(']'); + return; + } + json.append('\n'); + for (int i = 0; i < list.size(); i++) { + pad(json, indent + 1); + encode(json, list.get(i), indent + 1); + json.append(i + 1 < list.size() ? ",\n" : "\n"); + } + pad(json, indent); + json.append(']'); + return; + } + json.append('"').append(escapeJson(String.valueOf(value))).append('"'); + } + + private static void pad(StringBuilder json, int indent) { + json.append(" ".repeat(Math.max(0, indent))); + } + + static String escape(String value) { + if (value == null || value.isEmpty()) return ""; + StringBuilder escaped = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '&' -> escaped.append("&"); + case '<' -> escaped.append("<"); + case '>' -> escaped.append(">"); + case '"' -> escaped.append("""); + case '\'' -> escaped.append("'"); + default -> escaped.append(ch); + } + } + return escaped.toString(); + } + + private static String escapeJson(String value) { + StringBuilder escaped = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> { + if (ch < 0x20) { + escaped.append(String.format(Locale.ROOT, "\\u%04x", (int) ch)); + } else { + escaped.append(ch); + } + } + } + } + return escaped.toString(); + } + + private record ScenarioIdentity(String name, String feature, String scenarioId) { } +} diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index a4e31c99..9e72c1be 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -4,27 +4,31 @@ 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 -The Workbench controller owns synchronization, worker process/session lifecycle, bridge client behavior, MCP stdio, and the thin Swing UI. Pickleball owns consumer-worker behavior such as the bridge server/coordinator, DynamicControl/Gherkin execution, Step Override runtime, Mapping state, browser/service-call access, and woven Cucumber integration. +The Workbench controller owns synchronization, worker process/session lifecycle, bridge client behavior, MCP stdio, the localhost UI-attach endpoint, the watched-agent control lease, and the thin Swing UI. Pickleball owns consumer-worker behavior such as the bridge server/coordinator, DynamicControl/Gherkin execution, Step Override runtime, Mapping state, browser/service-call access, and woven Cucumber integration. -`WorkbenchServices` is the shared plain-Java adapter boundary. `WorkbenchController` composes synchronization and `WorkbenchLiveSession`; MCP and Swing must delegate to that service surface instead of implementing their own worker ownership, bridge calls, Mapping semantics, Step Override behavior, or scenario retry rules. +`WorkbenchServices` is the shared plain-Java adapter boundary. `WorkbenchController` composes synchronization, `WorkbenchLiveSession`, `LiveScenarioPlayer`, and the control lease; MCP, HTTP attach, and Swing must delegate to that service surface instead of implementing their own worker ownership, bridge calls, Mapping semantics, Step Override behavior, scenario retry rules, or a second live Gherkin document. -The Workbench bridge client uses 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: @@ -47,19 +51,49 @@ 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 rail: scenario name/tag filters + results; optional feature-file filter +center: Live Gherkin editor (Text | Blocks) + compact Step Editor / Command +right: Mapping | Terminal | Diagnostic Log Explorer +``` + +Low-level lifecycle controls live under the Session menu and existing investigation controls remain available under Advanced Controls rather than dominating the permanent workspace. + +`LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, the editable Gherkin document, selected line, playhead, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. + +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer after filtering by scenario name (starts with / contains / ends with / full match; default contains; all case-insensitive) and Cucumber tags (include AND, exclude NOT, with Feature/Rule/outline/Examples inheritance parsed from the `.feature` files). Feature-file selection is a collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless **Save** is explicitly approved. Human Save asks before copying the live scenario into the original scenario in the original `.feature` file. An attached agent must use `workbench_request_save` and wait for Allow/Deny when the UI is present. Deny and Take control write nothing. A prominent **Text | Blocks** toggle shows the same live buffer as ordinary Gherkin or as the WebView block editor without losing playhead, selection, or document text. If JavaFX/WebView is unavailable, Text is the fallback and Blocks stays honestly unavailable. WebView JavaScript must not execute Gherkin. -The 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. +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 + +Buffered Play / From Here / add-and-continue now execute through the existing live `executeStep` contract. Swing remains a presentation adapter: it sends displayed Gherkin unchanged and never owns a second worker manager, Mapping implementation, or Pickleball runtime. + +The Mapping tab must not hard-code NodeMap names. It is one current-ParsingMap NodeMap selector plus a structured property tree. Typed edits go through `mappingPut`; renames/object replacement use `mappingRestore`. Do not create a fake ParsingMap in Swing or WebView. + +The Terminal tab tails the existing worker stdout/stderr files and filters TRACE–ERROR. Do not implement it by redirecting MCP stdout or inventing log lines. The Diagnostic Log Explorer binds to Pickleball's retained diagnostic artifacts and evidence-escalation model. Do not populate either tab with fake production data. + +Heavy panels use Workbench-only OpenJFX `WebView` (`JFXPanel`). That choice is documented in `docs/pickleball-workbench.md`. Do not add JCEF or Pickleball-core UI dependencies. + +Existing capabilities remain available: project/synchronization status, worker lifecycle, live raw Gherkin, Mapping get/put/resolve, semantic events, Step Override list/compile/remove/clear, browser page/screenshot evidence, service-call evidence, and semantic breakpoint list/add/remove/clear. 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. When an agent holds the control lease, lock picker/filter fields, scenario list, feature-filter disclosure, the Text | Blocks toggle, and the live editor the same way other play/edit controls lock. Semantic-event cursors are worker-local and must reset when a fresh worker is started/restarted. Prefer headless-safe tests around player state, catalog/filter models, editor-view toggling, and presentation/controller delegation rather than tests requiring a visible desktop. ## MCP stdio @@ -69,7 +103,9 @@ Workbench provides: java -jar pickleball-workbench-.jar mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, and Step Override authoring through `WorkbenchServices`. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, and `workbench_investigation_emit` through `WorkbenchServices`. Consumer agents use this headless stdio server (`mcp .`), not the Swing GUI. + +UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. MCP stdout is a hard protocol boundary. `WorkbenchApplication` reserves the original process stdout for the stdio transport and redirects ordinary `System.out` output to stderr before constructing the MCP SDK/controller. The executable must explicitly remain alive for the stdio session until stdin reaches EOF; do not rely on MCP SDK worker-thread liveness to keep the JVM running. Workbench diagnostic text must use stderr or `.pickleball/workbench/logs/`; worker stdout/stderr remain separately redirected to worker log files. No banner, normal log, worker output, test output, or diagnostic chatter may be written to MCP stdout. @@ -79,11 +115,11 @@ Do not add generic IDE/file/build/process/collaboration tools to this MCP surfac ## Synchronization and worker lifecycle -Workbench synchronization is build-tool-assisted, not a replacement build system. Use the selected Maven/Gradle wrapper to establish compiled main/test output, processed resources, and the effective test runtime classpath. Gradle synchronization must use build-native init-script/task injection rather than the Gradle Tooling API. +Workbench synchronization is build-tool-assisted, not a replacement build system. Use the selected Maven/Gradle wrapper to establish compiled main/test output, processed resources, and the effective test runtime classpath. Gradle synchronization must use build-native init-script/task injection rather than the Gradle Tooling API. Compare **input** fingerprints (Java sources, resources, build files, dependency artifact bytes) to the last manifest before invoking the wrapper: skip when nothing that requires recompilation changed; run resource processing only when only feature/config/data changed; run full `test-compile` / `testClasses` when Java, the build descriptor, or dependencies changed. The output fingerprint in `manifest.json` is provenance, not the skip key. Always pass `-DskipTests`. Live Gherkin buffer edits must never require sync. If compiled project outputs are missing after a clean, escalate resources-only to full compile. -`.pickleball/workbench/base/classes` is synchronization provenance/reset state and must never be on a worker runtime classpath. `.pickleball/workbench/live/classes` is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. +`.pickleball/workbench/base/classes` is synchronization provenance/reset state and must never be on a worker runtime classpath. `.pickleball/workbench/live/classes` is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. Do not treat `live/classes` as an editor. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. -The controller owns one interactive worker per selected project by default. Workers launch directly with Java from the existing Workbench snapshot, use the Pickleball-side `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. @@ -91,7 +127,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. @@ -100,3 +136,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/WORKBENCH-PLAYER-CONTEXT.md b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md new file mode 100644 index 00000000..31bd0270 --- /dev/null +++ b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md @@ -0,0 +1,53 @@ +# 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. + +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it, like clicking a waveform. Global Play ignores the playhead and always starts from the first executable buffer step in a fresh interactive worker context. + +The Live Scenario Editor is an in-place Gherkin document shown as WebView blocks whose text is Gherkin. Users can type at any block, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files unless **Save** is explicitly approved. Human Save confirms before copying into the original scenario. An attached AI agent can request the controller-owned control lease, lock Swing while the human watches, and must ask permission to write the original feature. Take control always returns the floor to the human. + +The Step Editor exposes two distinct execution actions: + +- **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/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 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. + +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. + +## Mapping editor contract + +The primary Mapping tab has no get/put/resolve form. It is: + +- one current-ParsingMap NodeMap dropdown; +- a typed property tree (string / numeric / boolean / object-as-JSON / object-as-XML) persisted through `mappingPut` and `mappingRestore`. + +Current NodeMaps are discovered worker-side through `MappingControl` using the reserved neutral references in `ControlProtocol`. Workbench sees only `ControlBridgeMappingSnapshot` data and must never import `ParsingMap`, `NodeMap`, or other Pickleball runtime classes. + +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 f4198d5c..d251577d 100644 --- a/pickleball-workbench/build.gradle +++ b/pickleball-workbench/build.gradle @@ -20,11 +20,42 @@ java { } def mcpSdkVersion = '2.0.0' +def javafxVersion = '21.0.6' +def javafxModules = ['base', 'graphics', 'controls', 'swing', 'media', 'web'] +def javafxPlatforms = ['linux', 'win', 'mac', 'mac-aarch64'] +def javafxCompilePlatform = { + def os = org.gradle.internal.os.OperatingSystem.current() + def arch = System.getProperty('os.arch', '') + boolean arm = arch.contains('aarch64') || arch.contains('arm64') + if (os.windows) return 'win' + if (os.macOsX) return arm ? 'mac-aarch64' : 'mac' + return 'linux' +}() + +configurations { + javafxNatives +} dependencies { - // 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') + + // OpenJFX WebView is Workbench-only presentation. It is not a Pickleball + // runtime dependency and is preferred over JCEF so natives stay in Maven + // artifacts that can be shaded into the controller executable. + javafxModules.each { module -> + implementation "org.openjfx:javafx-${module}:${javafxVersion}:${javafxCompilePlatform}" + } + javafxPlatforms.each { platform -> + javafxModules.each { module -> + javafxNatives "org.openjfx:javafx-${module}:${javafxVersion}:${platform}" + } + } + + // Controller-side JSON transport. This is declared directly instead of + // inheriting it through Pickleball core. + implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.0' // Keep MCP Workbench-only and non-Spring. Jackson 2 matches Pickleball's // existing ObjectMapper usage without pulling the SDK convenience/Jackson 3 path. @@ -33,6 +64,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 { @@ -55,13 +87,57 @@ tasks.jar { enabled = false } +def javafxNativeStage = tasks.register('stageJavaFxNatives') { + def outputDir = layout.buildDirectory.dir('javafx-natives') + outputs.dir(outputDir) + doLast { + def dest = outputDir.get().asFile + dest.deleteDir() + dest.mkdirs() + configurations.javafxNatives.resolvedConfiguration.resolvedArtifacts.each { artifact -> + String classifier = artifact.classifier ?: 'unknown' + def target = new File(dest, classifier) + target.mkdirs() + copy { + from zipTree(artifact.file) + into target + include '**/*.so' + include '**/*.dll' + include '**/*.dylib' + include '**/*.jnilib' + includeEmptyDirs = false + eachFile { details -> + details.path = details.name + } + } + new File(target, '.keep').text = '' + } + } +} + +tasks.named('processResources') { + dependsOn javafxNativeStage + from(layout.buildDirectory.dir('javafx-natives')) { + into 'javafx-natives' + } +} + tasks.shadowJar { + dependsOn javafxNativeStage archiveBaseName.set('pickleball-workbench') archiveVersion.set(project.version.toString()) archiveClassifier.set('') duplicatesStrategy = DuplicatesStrategy.EXCLUDE mergeServiceFiles() zip64 = true + exclude 'module-info.class' + exclude { details -> + String path = details.path ?: '' + String name = details.name ?: '' + boolean nativeLib = name.endsWith('.so') || name.endsWith('.dll') || + name.endsWith('.dylib') || name.endsWith('.jnilib') + nativeLib && !path.startsWith('javafx-natives/') + } manifest { attributes( @@ -132,8 +208,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 +217,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 +262,61 @@ 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 = { InputStream input, String source -> + new java.util.zip.ZipInputStream(input).withCloseable { nested -> + java.util.zip.ZipEntry entry + while ((entry = nested.nextEntry) != null) { + String name = entry.name + if (forbiddenPrefixes.any { name.startsWith(it) }) { + forbidden.add("${source}!/${name}") + } + if (!entry.directory && name.endsWith('.jar')) { + scanNestedJar( + new ByteArrayInputStream(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 +325,116 @@ 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')) { + jar.getInputStream(entry).withCloseable { stream -> + scanNestedJar(stream, name) + } + } else if (!entry.directory && name.startsWith('META-INF/services/')) { + String providers = jar.getInputStream(entry).getText('UTF-8') + if (providers.readLines().collect { line -> + 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 +457,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}") } @@ -442,7 +574,10 @@ tasks.register('verifyWorkbenchMcpStdio') { if (!names.containsAll([ 'workbench_worker_status', 'workbench_execute_step', - 'workbench_step_override_compile' + 'workbench_step_override_compile', + 'workbench_request_control', + 'workbench_player_state', + 'workbench_request_save' ])) { throw new GradleException("Workbench MCP tool list is incomplete: ${names}") } @@ -509,7 +644,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..16ef3d90 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); @@ -428,6 +436,7 @@ private static void printUsage(PrintStream out) { out.println("worker-check starts, restarts, and gracefully stops direct consumer workers without rebuilding."); out.println("live-check exercises raw Gherkin, Step Override, and live runtime operations on one persistent worker."); out.println("mcp serves the same Workbench services over protocol-only stdio; diagnostics use stderr/log files."); + out.println("ui opens the thin Swing Workbench over the same controller services and writes a localhost agent-attach endpoint to .pickleball/workbench/attach.json."); out.println("ui opens the thin Swing Workbench over the same controller services."); } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index 9ede6209..95dc025d 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -1,28 +1,218 @@ package tools.dscode.workbench; -import tools.dscode.control.bridge.*; +import tools.dscode.control.protocol.*; +import tools.dscode.workbench.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.lease.WorkbenchPermissionCancelledException; +import tools.dscode.workbench.lease.WorkbenchPermissionDecision; +import tools.dscode.workbench.lease.WorkbenchPermissionKind; +import tools.dscode.workbench.lease.WorkbenchPermissionRequest; +import tools.dscode.workbench.player.LiveFeatureSave; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.ScenarioOrigin; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; +import tools.dscode.workbench.diagnostics.DiagnosticEvidenceNavigator; +import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchLiveSession; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; +import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.Supplier; /** Plain-Java controller shared by Workbench adapters. */ public final class WorkbenchController implements WorkbenchServices { private final Path projectRoot; private final WorkbenchSynchronizer synchronizer; private final WorkbenchLiveSession live; + private final LiveScenarioPlayer player; + private final LivePlaybackCoordinator playback; + private final WorkbenchControlLease lease; + private final DiagnosticEvidenceNavigator diagnostics; + private final List playerListeners = new CopyOnWriteArrayList<>(); public WorkbenchController(Path projectRoot) { this.projectRoot = projectRoot.toAbsolutePath().normalize(); this.synchronizer = new WorkbenchSynchronizer(); this.live = new WorkbenchLiveSession(this.projectRoot); + this.player = LiveScenarioPlayer.interactiveBuffer(); + this.playback = new LivePlaybackCoordinator(this.player); + this.lease = new WorkbenchControlLease(); + this.diagnostics = new DiagnosticEvidenceNavigator(this.projectRoot); + } + + @Override + public LiveScenarioPlayer player() { + return player; + } + + @Override + public LivePlaybackCoordinator playback() { + return playback; + } + + @Override + public WorkbenchPlayerState playerState() { + ScenarioOrigin origin = playback.origin(); + LiveScenarioPlayer.Line playhead = player.playheadLine().orElse(null); + return new WorkbenchPlayerState( + player.documentText(), + player.lines().stream().map(LiveScenarioPlayer.Line::text).toList(), + player.state(), + player.playheadId().isPresent() ? player.playheadId().getAsLong() : null, + playhead == null ? "" : playhead.text(), + player.selectedId().isPresent() ? player.selectedId().getAsLong() : null, + origin.file() == null ? "" : origin.file().toString(), + origin.scenarioName(), + origin.savable() + ); + } + + @Override + public WorkbenchControlLease controlLease() { + return lease; + } + + @Override + public WorkbenchControlLeaseSnapshot controlLeaseSnapshot() { + return lease.snapshot(); + } + + @Override + public WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName) { + return lease.requestControl(agentDisplayName); + } + + @Override + public WorkbenchControlLeaseSnapshot releaseControl() { + return lease.releaseControl(); + } + + @Override + public WorkbenchControlLeaseSnapshot takeControl() { + return lease.takeControl(); + } + + @Override + public WorkbenchControlLeaseSnapshot setCurrentAction(String text) { + return lease.setCurrentAction(text); + } + + @Override + public void answerPermission(String requestId, boolean allow) { + lease.answerPermission(requestId, allow); + } + + @Override + public void attachUi() { + lease.attachUi(); + } + + @Override + public void detachUi() { + lease.detachUi(); + } + + @Override + public void addLeaseListener(Consumer listener) { + lease.addListener(listener); + } + + @Override + public void removeLeaseListener(Consumer listener) { + lease.removeListener(listener); + } + + @Override + public void addPlayerListener(Runnable listener) { + playerListeners.add(listener); + } + + @Override + public void removePlayerListener(Runnable listener) { + playerListeners.remove(listener); + } + + @Override + public void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + requireMutating(); + playback.loadScenario(lines, originFile, scenarioName, startLine, endLine); + notifyPlayer(); + } + + @Override + public void loadDefaultDemo() { + requireMutating(); + playback.loadDefaultDemo(); + notifyPlayer(); + } + + @Override + public void replaceLiveDocument(List lines) { + requireMutating(); + playback.replaceFromLines(lines); + notifyPlayer(); + } + + @Override + public WorkbenchSavePreview savePreview() { + return LiveFeatureSave.preview(playback); + } + + @Override + public WorkbenchSaveResult requestSave() { + requireMutating(); + WorkbenchSavePreview preview = LiveFeatureSave.preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + WorkbenchPermissionRequest request = new WorkbenchPermissionRequest( + WorkbenchControlLease.newPermissionId(), + WorkbenchPermissionKind.SAVE, + preview.summary(), + preview.featurePath() == null ? "" : preview.featurePath().toString(), + preview.scenarioName() + ); + try { + WorkbenchPermissionDecision decision = lease.awaitPermission(request); + if (decision != WorkbenchPermissionDecision.ALLOW) { + return WorkbenchSaveResult.denied(); + } + return LiveFeatureSave.write(playback); + } catch (WorkbenchPermissionCancelledException cancelled) { + return WorkbenchSaveResult.cancelled(cancelled.getMessage()); + } + } + + @Override + public WorkbenchSaveResult commitSave() { + requireMutating(); + WorkbenchSavePreview preview = LiveFeatureSave.preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + return LiveFeatureSave.write(playback); } @Override public WorkbenchManifest synchronize() { + requireMutating(); if (live.status().running()) { throw new IllegalStateException("Stop the Workbench worker before synchronizing the project."); } @@ -36,17 +226,17 @@ public WorkbenchManifest synchronizationStatus() { @Override public WorkbenchWorkerStatus startWorker() { - return live.start(); + return mutating(live::start); } @Override public WorkbenchWorkerStatus restartWorker() { - return live.restart(); + return mutating(live::restart); } @Override public WorkbenchWorkerStatus stopWorker() { - return live.stop(); + return mutating(live::stop); } @Override @@ -54,9 +244,22 @@ public WorkbenchWorkerStatus workerStatus() { return live.status(); } + @Override + public Path projectRoot() { + return projectRoot; + } + + @Override + public Optional workerLogFiles() { + return live.workerLogFiles(); + } + @Override public ControlBridgeCallResult executeStep(String text, String argument) { - return live.executeStep(text, argument == null ? "" : argument); + requireMutating(); + ControlBridgeCallResult result = live.executeStep(text, argument == null ? "" : argument); + maybeAdvancePlayhead(text, "SUCCESS".equals(result.status())); + return result; } @Override @@ -66,7 +269,7 @@ public ControlBridgeValueResult mappingGet(String mapReference, String key) { @Override public ControlBridgeValueResult mappingPut(String mapReference, String key, Object value) { - return live.mappingPut(mapReference, key, value); + return mutating(() -> live.mappingPut(mapReference, key, value)); } @Override @@ -81,7 +284,7 @@ public ControlBridgeMappingSnapshotResult mappingSnapshot(String mapReference) { @Override public ControlBridgeCallResult mappingRestore(ControlBridgeMappingSnapshot snapshot) { - return live.mappingRestore(snapshot); + return mutating(() -> live.mappingRestore(snapshot)); } @Override @@ -108,7 +311,7 @@ public ControlBridgeElementInspectionResult elementInspect( @Override public ControlBridgeServiceCallResult serviceCall(String selector) { - return live.serviceCall(selector); + return mutating(() -> live.serviceCall(selector)); } @Override @@ -125,19 +328,19 @@ public ControlBridgeBreakpoint addBreakpoint( boolean oneShot, Integer leaseSeconds ) { - return live.addBreakpoint( + return mutating(() -> live.addBreakpoint( hook, signatureContains, stepContains, phraseContains, oneShot, leaseSeconds - ); + )); } @Override public boolean removeBreakpoint(String breakpointId) { - return live.removeBreakpoint(breakpointId); + return mutating(() -> live.removeBreakpoint(breakpointId)); } @Override public int clearBreakpoints() { - return live.clearBreakpoints(); + return mutating(live::clearBreakpoints); } @Override @@ -147,21 +350,74 @@ public List stepOverrides() { @Override public ControlBridgeStepOverrideResult compileStepOverride(String id, String regex, String source) { - return live.compileStepOverride(id, regex, source); + return mutating(() -> live.compileStepOverride(id, regex, source)); } @Override public boolean removeStepOverride(String id) { - return live.removeStepOverride(id); + return mutating(() -> live.removeStepOverride(id)); } @Override public int clearStepOverrides() { - return live.clearStepOverrides(); + return mutating(live::clearStepOverrides); + } + + @Override + public Object diagnosticCatalog() { + return diagnostics.catalogDocument(); + } + + @Override + public Object diagnosticRun(String runId) { + return diagnostics.runDocument(runId); + } + + @Override + public Object diagnosticScenarioSummary(String runId, String scenarioId) { + return diagnostics.scenarioSummaryDocument(runId, scenarioId); + } + + @Override + public Object emitInvestigation(Map investigation) { + try { + return InvestigationHandoff.emit(projectRoot, investigation).sparseResult(); + } catch (IOException failure) { + throw new IllegalStateException("Could not emit investigation handoff.", failure); + } } @Override public void close() { + lease.detachUi(); live.close(); } + + private void maybeAdvancePlayhead(String text, boolean successful) { + try { + playback.followExecutedStep(text, successful); + } catch (RuntimeException ignored) { + // Playhead follow is best-effort presentation; worker execution already finished. + } + notifyPlayer(); + } + + private T mutating(Supplier action) { + requireMutating(); + return action.get(); + } + + private void requireMutating() { + lease.requireMutatingAccess(); + } + + private void notifyPlayer() { + for (Runnable listener : playerListeners) { + try { + listener.run(); + } catch (RuntimeException ignored) { + // Presentation listeners must not break live execution. + } + } + } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/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..ebff8984 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -1,24 +1,86 @@ 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.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; +import java.nio.file.Path; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; /** Shared Workbench controller surface used by protocol and presentation adapters. */ public interface WorkbenchServices extends AutoCloseable { + LiveScenarioPlayer player(); + + LivePlaybackCoordinator playback(); + + WorkbenchPlayerState playerState(); + + WorkbenchControlLease controlLease(); + + WorkbenchControlLeaseSnapshot controlLeaseSnapshot(); + + WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName); + + WorkbenchControlLeaseSnapshot releaseControl(); + + WorkbenchControlLeaseSnapshot takeControl(); + + WorkbenchControlLeaseSnapshot setCurrentAction(String text); + + void answerPermission(String requestId, boolean allow); + + void attachUi(); + + void detachUi(); + + void addLeaseListener(Consumer listener); + + void removeLeaseListener(Consumer listener); + + void addPlayerListener(Runnable listener); + + void removePlayerListener(Runnable listener); + + void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ); + + void loadDefaultDemo(); + + void replaceLiveDocument(List lines); + + WorkbenchSavePreview savePreview(); + + WorkbenchSaveResult requestSave(); + + WorkbenchSaveResult commitSave(); + WorkbenchManifest synchronize(); WorkbenchManifest synchronizationStatus(); @@ -31,6 +93,10 @@ public interface WorkbenchServices extends AutoCloseable { WorkbenchWorkerStatus workerStatus(); + Path projectRoot(); + + Optional workerLogFiles(); + ControlBridgeCallResult executeStep(String text, String argument); ControlBridgeValueResult mappingGet(String mapReference, String key); @@ -78,6 +144,14 @@ ControlBridgeBreakpoint addBreakpoint( int clearStepOverrides(); + Object diagnosticCatalog(); + + Object diagnosticRun(String runId); + + Object diagnosticScenarioSummary(String runId, String scenarioId); + + Object emitInvestigation(Map investigation); + @Override void close(); } 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/catalog/ConsumerFeatureCatalog.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java new file mode 100644 index 00000000..b05f6e26 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java @@ -0,0 +1,452 @@ +package tools.dscode.workbench.catalog; + +import tools.dscode.workbench.sync.WorkbenchManifest; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +/** + * Project-owned feature/scenario index for the Workbench picker. + * + *

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

+ * + *

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

+ */ +public final class ConsumerFeatureCatalog { + public enum BrowseMode { + FEATURE_NAME, + FILE_PATH + } + + public record FeatureEntry( + Path file, + String relativePath, + String featureName, + String directoryPath, + String fileName, + List scenarios, + List tags + ) { + public FeatureEntry { + Objects.requireNonNull(file, "file"); + relativePath = relativePath == null ? "" : relativePath; + featureName = featureName == null || featureName.isBlank() ? fileName : featureName; + directoryPath = directoryPath == null ? "" : directoryPath; + fileName = fileName == null ? file.getFileName().toString() : fileName; + scenarios = List.copyOf(scenarios == null ? List.of() : scenarios); + tags = ScenarioFilter.copyTags(tags); + } + + public String browseLabel(BrowseMode mode) { + if (mode == BrowseMode.FILE_PATH) { + return directoryPath.isBlank() ? fileName : directoryPath + "/" + fileName; + } + return featureName; + } + } + + public record ScenarioEntry( + String name, + String featureName, + Path file, + String relativePath, + int startLine, + int endLine, + List lines, + List tags, + List effectiveTags + ) { + public ScenarioEntry { + name = name == null ? "" : name; + featureName = featureName == null ? "" : featureName; + Objects.requireNonNull(file, "file"); + relativePath = relativePath == null ? "" : relativePath; + lines = List.copyOf(lines == null ? List.of() : lines); + tags = ScenarioFilter.copyTags(tags); + effectiveTags = ScenarioFilter.copyTags(effectiveTags); + } + + public String displayLabel() { + return name.isBlank() ? "(unnamed scenario)" : name; + } + } + + private final Path projectRoot; + private final List features; + private final Set selectedFeatures = new LinkedHashSet<>(); + private final ScenarioFilter filter = new ScenarioFilter(); + private BrowseMode browseMode = BrowseMode.FEATURE_NAME; + + public ConsumerFeatureCatalog(Path projectRoot, WorkbenchManifest manifest) { + this(projectRoot, discover(projectRoot, manifest)); + } + + ConsumerFeatureCatalog(Path projectRoot, List features) { + this.projectRoot = projectRoot.toAbsolutePath().normalize(); + this.features = List.copyOf(features); + } + + public static ConsumerFeatureCatalog scan(Path projectRoot, WorkbenchManifest manifest) { + return new ConsumerFeatureCatalog(projectRoot, manifest); + } + + public Path projectRoot() { + return projectRoot; + } + + public BrowseMode browseMode() { + return browseMode; + } + + public void setBrowseMode(BrowseMode browseMode) { + this.browseMode = browseMode == null ? BrowseMode.FEATURE_NAME : browseMode; + } + + public ScenarioFilter filter() { + return filter; + } + + public String scenarioQuery() { + return filter.nameQuery(); + } + + public void setScenarioQuery(String scenarioQuery) { + filter.setNameQuery(scenarioQuery); + } + + public List features() { + return features; + } + + public List featuresForBrowse() { + List copy = new ArrayList<>(features); + if (browseMode == BrowseMode.FILE_PATH) { + copy.sort(Comparator + .comparing(FeatureEntry::directoryPath, String.CASE_INSENSITIVE_ORDER) + .thenComparing(FeatureEntry::fileName, String.CASE_INSENSITIVE_ORDER)); + } else { + copy.sort(Comparator.comparing(FeatureEntry::featureName, String.CASE_INSENSITIVE_ORDER)); + } + return List.copyOf(copy); + } + + public boolean selected(Path file) { + return selectedFeatures.contains(file.toAbsolutePath().normalize()); + } + + public void toggleFeature(Path file) { + Path key = file.toAbsolutePath().normalize(); + if (!selectedFeatures.add(key)) { + selectedFeatures.remove(key); + } + } + + public void selectFeature(Path file) { + selectedFeatures.add(file.toAbsolutePath().normalize()); + } + + public void deselectFeature(Path file) { + selectedFeatures.remove(file.toAbsolutePath().normalize()); + } + + public void clearFeatureSelection() { + selectedFeatures.clear(); + } + + public List selectedFeatureFiles() { + return List.copyOf(selectedFeatures); + } + + /** + * Scenarios that pass the optional feature-file filter. With no feature + * selected, every catalog scenario is a candidate; name/tag filters then + * apply to that pool. + */ + public List candidateScenarios() { + List pool = new ArrayList<>(); + for (FeatureEntry feature : features) { + if (selectedFeatures.isEmpty() || selectedFeatures.contains(feature.file())) { + pool.addAll(feature.scenarios()); + } + } + return List.copyOf(pool); + } + + /** + * Candidate scenarios after the primary name/tag filter. Feature-file + * selection is optional and does not restrict the pool when empty. + */ + public List visibleScenarios() { + List pool = new ArrayList<>(filter.apply(candidateScenarios())); + pool.sort(Comparator + .comparing(ScenarioEntry::featureName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(ScenarioEntry::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(pool); + } + + public FeatureEntry feature(Path file) { + Path key = file.toAbsolutePath().normalize(); + return features.stream() + .filter(feature -> feature.file().equals(key)) + .findFirst() + .orElse(null); + } + + static List discover(Path projectRoot, WorkbenchManifest manifest) { + Path root = projectRoot.toAbsolutePath().normalize(); + LinkedHashSet searchRoots = new LinkedHashSet<>(); + addIfDirectory(searchRoots, root.resolve("src").resolve("test").resolve("resources").resolve("features")); + addIfDirectory(searchRoots, root.resolve("src").resolve("test").resolve("resources")); + if (manifest != null) { + for (String source : manifest.sourceRoots()) { + if (source == null || source.isBlank()) continue; + Path sourceRoot = Path.of(source).toAbsolutePath().normalize(); + addIfDirectory(searchRoots, sourceRoot); + addIfDirectory(searchRoots, sourceRoot.resolve("features")); + } + if (manifest.liveOutput() != null && !manifest.liveOutput().isBlank()) { + Path live = Path.of(manifest.liveOutput()).toAbsolutePath().normalize(); + addIfDirectory(searchRoots, live.resolve("features")); + } + } + for (Path configured : configuredFeatureRoots(root)) { + addIfDirectory(searchRoots, configured); + } + + LinkedHashSet files = new LinkedHashSet<>(); + for (Path searchRoot : searchRoots) { + collectFeatureFiles(searchRoot, files); + } + + List entries = new ArrayList<>(); + LinkedHashSet seen = new LinkedHashSet<>(); + for (Path file : files) { + Path absolute = file.toAbsolutePath().normalize(); + if (!seen.add(absolute)) continue; + try { + entries.add(readFeature(root, absolute)); + } catch (IOException ignored) { + // Skip unreadable files; the picker should not fail the whole catalog. + } + } + entries.sort(Comparator.comparing(FeatureEntry::relativePath, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(entries); + } + + static List configuredFeatureRoots(Path projectRoot) { + List roots = new ArrayList<>(); + List propertyFiles = List.of( + projectRoot.resolve("src").resolve("test").resolve("resources").resolve("pickleball.properties"), + projectRoot.resolve("src").resolve("main").resolve("resources").resolve("pickleball.properties"), + projectRoot.resolve("pickleball.properties") + ); + for (Path file : propertyFiles) { + if (!Files.isRegularFile(file)) continue; + try { + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + String trimmed = line.strip(); + if (trimmed.startsWith("#") || !trimmed.contains("pkb_features")) continue; + String value = featurePathValue(trimmed); + Path resolved = resolveConfiguredFeatures(projectRoot, value); + if (resolved != null) roots.add(resolved); + } + } catch (IOException ignored) { + // Configuration is optional discovery input. + } + } + return roots; + } + + static String featurePathValue(String line) { + int equals = line.indexOf('='); + if (equals < 0) return ""; + String raw = line.substring(equals + 1).strip(); + if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length() >= 2) { + raw = raw.substring(1, raw.length() - 1); + } + return raw; + } + + static Path resolveConfiguredFeatures(Path projectRoot, String value) { + if (value == null || value.isBlank()) return null; + String path = value.strip(); + if (path.startsWith("classpath:")) { + String remainder = path.substring("classpath:".length()).replace('\\', '/'); + while (remainder.startsWith("/")) remainder = remainder.substring(1); + Path testResources = projectRoot.resolve("src").resolve("test").resolve("resources"); + return remainder.isBlank() ? testResources : testResources.resolve(remainder); + } + if (path.startsWith("file:")) { + path = path.substring("file:".length()); + } + Path configured = Path.of(path); + return configured.isAbsolute() ? configured.normalize() : projectRoot.resolve(configured).normalize(); + } + + static FeatureEntry readFeature(Path projectRoot, Path file) throws IOException { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + String featureName = ""; + List featureTags = List.of(); + List ruleTags = List.of(); + List pendingTags = new ArrayList<>(); + String currentScenario = null; + int scenarioStart = -1; + boolean currentIsOutline = false; + List currentOwnTags = List.of(); + LinkedHashSet currentExampleTags = new LinkedHashSet<>(); + List header = new ArrayList<>(); + List scenarios = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + String trimmed = lines.get(i).strip(); + if (ScenarioFilter.isGherkinTagLine(trimmed)) { + pendingTags.addAll(ScenarioFilter.parseGherkinTagLine(trimmed)); + continue; + } + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + if (startsWithKeyword(trimmed, "Feature:")) { + featureName = trimmed.substring("Feature:".length()).strip(); + featureTags = List.copyOf(pendingTags); + ruleTags = List.of(); + pendingTags.clear(); + header.add(lines.get(i)); + } else if (startsWithKeyword(trimmed, "Rule:")) { + ruleTags = List.copyOf(pendingTags); + pendingTags.clear(); + } else if (startsWithKeyword(trimmed, "Scenario Outline:") + || startsWithKeyword(trimmed, "Scenario Template:") + || startsWithKeyword(trimmed, "Scenario:")) { + if (currentScenario != null) { + scenarios.add(scenario( + projectRoot, file, featureName, featureTags, ruleTags, + currentScenario, currentOwnTags, currentExampleTags, + scenarioStart, i - 1, lines, header + )); + } + currentIsOutline = startsWithKeyword(trimmed, "Scenario Outline:") + || startsWithKeyword(trimmed, "Scenario Template:"); + currentScenario = trimmed.contains(":") + ? trimmed.substring(trimmed.indexOf(':') + 1).strip() + : trimmed; + scenarioStart = i; + currentOwnTags = List.copyOf(pendingTags); + currentExampleTags.clear(); + pendingTags.clear(); + } else if (startsWithKeyword(trimmed, "Examples:") || startsWithKeyword(trimmed, "Example:")) { + if (currentIsOutline) { + currentExampleTags.addAll(pendingTags); + } + pendingTags.clear(); + } else { + pendingTags.clear(); + } + } + if (currentScenario != null) { + scenarios.add(scenario( + projectRoot, file, featureName, featureTags, ruleTags, + currentScenario, currentOwnTags, currentExampleTags, + scenarioStart, lines.size() - 1, lines, header + )); + } + Path relative = relativeTo(projectRoot, file); + String relativePath = relative.toString().replace('\\', '/'); + Path parent = relative.getParent(); + return new FeatureEntry( + file.toAbsolutePath().normalize(), + relativePath, + featureName, + parent == null ? "" : parent.toString().replace('\\', '/'), + file.getFileName().toString(), + scenarios, + featureTags + ); + } + + private static ScenarioEntry scenario( + Path projectRoot, + Path file, + String featureName, + List featureTags, + List ruleTags, + String name, + List ownTags, + Set exampleTags, + int start, + int end, + List lines, + List header + ) { + List body = new ArrayList<>(); + if (!header.isEmpty()) { + body.addAll(header); + if (body.getLast().isBlank() == false) body.add(""); + } + for (int i = start; i <= end && i < lines.size(); i++) { + body.add(lines.get(i)); + } + while (!body.isEmpty() && body.getLast().isBlank()) { + body.removeLast(); + } + LinkedHashSet effective = new LinkedHashSet<>(); + effective.addAll(featureTags); + effective.addAll(ruleTags); + effective.addAll(ownTags); + if (exampleTags != null) effective.addAll(exampleTags); + return new ScenarioEntry( + name, + featureName, + file.toAbsolutePath().normalize(), + relativeTo(projectRoot, file).toString().replace('\\', '/'), + start + 1, + end + 1, + body, + ownTags, + List.copyOf(effective) + ); + } + + private static boolean startsWithKeyword(String trimmed, String keyword) { + return trimmed.startsWith(keyword); + } + + private static void addIfDirectory(Set roots, Path path) { + if (path != null && Files.isDirectory(path)) { + roots.add(path.toAbsolutePath().normalize()); + } + } + + private static void collectFeatureFiles(Path root, Set files) { + if (!Files.isDirectory(root)) return; + try (Stream walk = Files.walk(root, 8)) { + walk.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".feature")) + .forEach(path -> files.add(path.toAbsolutePath().normalize())); + } catch (IOException ignored) { + // A single unreadable directory must not hide the rest of the catalog. + } + } + + private static Path relativeTo(Path projectRoot, Path file) { + try { + return projectRoot.relativize(file.toAbsolutePath().normalize()); + } catch (IllegalArgumentException ignored) { + return file.getFileName(); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java new file mode 100644 index 00000000..0dabdb79 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ScenarioFilter.java @@ -0,0 +1,232 @@ +package tools.dscode.workbench.catalog; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Headless name/tag filter for catalog scenarios. + * + *

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

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

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

+ */ +public final class DiagnosticEvidenceNavigator { + public enum Layer { + CATALOG, + RUN_INDEX, + CLUSTERS, + SUMMARY, + EVENTS, + COMPARISON, + SCREENSHOT, + TRACE + } + + public record CatalogRun(String runId, Path runRoot, JsonNode raw) { } + + public record ScreenshotFrame( + Path file, + String stepText, + String capturedAt, + String scenarioId + ) { } + + public record Timeline( + Path runRoot, + JsonNode runIndex, + JsonNode clusters, + List frames + ) { + public Timeline { + frames = List.copyOf(frames == null ? List.of() : frames); + } + } + + public record LayerView(Layer layer, Path path, boolean present, String excerpt) { } + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final Path projectRoot; + private final Path diagnosticRoot; + + public DiagnosticEvidenceNavigator(Path projectRoot) { + this(projectRoot, defaultDiagnosticRoot(projectRoot)); + } + + public DiagnosticEvidenceNavigator(Path projectRoot, Path diagnosticRoot) { + this.projectRoot = projectRoot.toAbsolutePath().normalize(); + this.diagnosticRoot = diagnosticRoot == null + ? defaultDiagnosticRoot(this.projectRoot) + : diagnosticRoot.toAbsolutePath().normalize(); + } + + public static Path defaultDiagnosticRoot(Path projectRoot) { + return projectRoot.toAbsolutePath().normalize().resolve("reports").resolve("diagnostic-runs"); + } + + public Path projectRoot() { + return projectRoot; + } + + public Path diagnosticRoot() { + return diagnosticRoot; + } + + public boolean available() { + return Files.isRegularFile(diagnosticRoot.resolve("run-catalog.json")); + } + + public List catalogRuns() { + Path catalog = diagnosticRoot.resolve("run-catalog.json"); + if (!Files.isRegularFile(catalog)) return List.of(); + JsonNode root = readJson(catalog); + List runs = new ArrayList<>(); + for (JsonNode node : catalogItems(root)) { + String runId = text(node, "runId", "id", "run"); + if (runId.isBlank()) continue; + Path runRoot = diagnosticRoot.resolve(runId); + runs.add(new CatalogRun(runId, runRoot, node)); + } + return List.copyOf(runs); + } + + public JsonNode catalogDocument() { + Path catalog = diagnosticRoot.resolve("run-catalog.json"); + var result = JSON.createObjectNode(); + result.put("available", Files.isRegularFile(catalog)); + result.put("path", catalog.toString()); + if (Files.isRegularFile(catalog)) { + result.set("catalog", readJson(catalog)); + } + return result; + } + + public JsonNode runDocument(String runId) { + Path runRoot = resolveContained(diagnosticRoot, runId, "runId"); + Path index = runRoot.resolve("run-index.json"); + Path clusters = runRoot.resolve("clusters.json"); + var result = JSON.createObjectNode(); + result.put("runId", runId); + result.put("runRoot", runRoot.toString()); + result.put("indexPresent", Files.isRegularFile(index)); + result.put("clustersPresent", Files.isRegularFile(clusters)); + if (Files.isRegularFile(index)) { + result.set("runIndex", readJson(index)); + } + if (Files.isRegularFile(clusters)) { + result.set("clusters", readJson(clusters)); + } + return result; + } + + public JsonNode scenarioSummaryDocument(String runId, String scenarioId) { + Path runRoot = resolveContained(diagnosticRoot, runId, "runId"); + Path scenarioDir = resolveContained(runRoot.resolve("scenarios"), scenarioId, "scenarioId"); + Path summary = scenarioDir.resolve("summary.json"); + if (!Files.isRegularFile(summary)) { + throw new IllegalArgumentException("No summary.json for scenario " + scenarioId + " in run " + runId); + } + var result = JSON.createObjectNode(); + result.put("runId", runId); + result.put("scenarioId", scenarioId); + result.put("path", summary.toString()); + result.set("summary", readJson(summary)); + return result; + } + + private static Path resolveContained(Path root, String name, String label) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + if (name.equals(".") || name.equals("..") + || name.contains("/") || name.contains("\\") || name.contains("..")) { + throw new IllegalArgumentException(label + " must be a simple directory name."); + } + Path base = root.toAbsolutePath().normalize(); + Path resolved = base.resolve(name).normalize(); + if (!resolved.startsWith(base)) { + throw new IllegalArgumentException(label + " is outside the diagnostic store."); + } + return resolved; + } + + public Timeline timeline(Path runRoot) { + Path root = runRoot.toAbsolutePath().normalize(); + JsonNode index = readJsonIfPresent(root.resolve("run-index.json")); + JsonNode clusters = readJsonIfPresent(root.resolve("clusters.json")); + List frames = new ArrayList<>(); + Path scenarios = root.resolve("scenarios"); + if (Files.isDirectory(scenarios)) { + try (var directories = Files.list(scenarios)) { + directories.filter(Files::isDirectory).forEach(scenarioDir -> + frames.addAll(framesForScenario(scenarioDir))); + } catch (IOException ignored) { + // Missing scenario folders are a retention gap, not a Workbench store. + } + } + frames.sort(Comparator + .comparing((ScreenshotFrame frame) -> frame.capturedAt() == null ? "" : frame.capturedAt()) + .thenComparing(frame -> frame.file().getFileName().toString())); + return new Timeline(root, index, clusters, frames); + } + + public List layers(Path runRoot, String scenarioId) { + Path root = runRoot.toAbsolutePath().normalize(); + Path scenarioDir = scenarioId == null || scenarioId.isBlank() + ? null + : root.resolve("scenarios").resolve(scenarioId); + List layers = new ArrayList<>(); + layers.add(layer(Layer.CATALOG, diagnosticRoot.resolve("run-catalog.json"), 40)); + layers.add(layer(Layer.RUN_INDEX, root.resolve("run-index.json"), 40)); + layers.add(layer(Layer.CLUSTERS, root.resolve("clusters.json"), 40)); + if (scenarioDir != null) { + layers.add(layer(Layer.SUMMARY, scenarioDir.resolve("summary.json"), 40)); + layers.add(layer(Layer.EVENTS, scenarioDir.resolve("events.jsonl"), 20)); + Path comparison = firstExisting( + scenarioDir.resolve("comparisonToPrevious.json"), + scenarioDir.resolve("comparison-to-previous.json") + ); + layers.add(layer(Layer.COMPARISON, comparison, 20)); + Path screenshots = scenarioDir.resolve("screenshots"); + layers.add(new LayerView( + Layer.SCREENSHOT, + screenshots, + Files.isDirectory(screenshots), + Files.isDirectory(screenshots) ? "PNG evidence directory" : "No screenshot directory" + )); + Path trace = firstExisting(scenarioDir.resolve("trace.jsonl.gz"), scenarioDir.resolve("trace.jsonl")); + layers.add(layer(Layer.TRACE, trace, 8)); + } + return List.copyOf(layers); + } + + public String readExcerpt(Path path, int maxLines) { + if (path == null || !Files.isRegularFile(path)) return ""; + try { + List lines = Files.readAllLines(path); + int limit = Math.max(1, maxLines); + if (lines.size() <= limit) return String.join("\n", lines); + return String.join("\n", lines.subList(0, limit)) + "\n..."; + } catch (IOException failure) { + return ""; + } + } + + private List framesForScenario(Path scenarioDir) { + Path screenshots = scenarioDir.resolve("screenshots"); + if (!Files.isDirectory(screenshots)) return List.of(); + JsonNode summary = readJsonIfPresent(scenarioDir.resolve("summary.json")); + List eventSteps = eventStepTexts(scenarioDir.resolve("events.jsonl")); + List pngs = new ArrayList<>(); + try (var files = Files.list(screenshots)) { + files.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".png")) + .sorted() + .forEach(pngs::add); + } catch (IOException ignored) { + return List.of(); + } + List frames = new ArrayList<>(); + for (int i = 0; i < pngs.size(); i++) { + Path png = pngs.get(i); + String step = stepForScreenshot(png, summary, eventSteps, i); + frames.add(new ScreenshotFrame( + png, + step, + fileTime(png), + scenarioDir.getFileName().toString() + )); + } + return frames; + } + + private static String stepForScreenshot(Path png, JsonNode summary, List eventSteps, int index) { + String named = screenshotStepFromSummary(summary, png.getFileName().toString()); + if (named != null && !named.isBlank()) return named; + if (index < eventSteps.size()) return eventSteps.get(index); + if (summary != null && summary.hasNonNull("lastStepText")) { + return summary.get("lastStepText").asText(); + } + return ""; + } + + private static String screenshotStepFromSummary(JsonNode summary, String fileName) { + if (summary == null) return ""; + JsonNode screenshots = summary.get("screenshots"); + if (screenshots == null || !screenshots.isArray()) return ""; + for (JsonNode item : screenshots) { + String name = text(item, "file", "path", "name"); + if (name.endsWith(fileName)) { + return text(item, "stepText", "step", "gherkin", "phrase"); + } + } + return ""; + } + + private static List eventStepTexts(Path events) { + if (!Files.isRegularFile(events)) return List.of(); + List steps = new ArrayList<>(); + try { + for (String line : Files.readAllLines(events)) { + if (line.isBlank()) continue; + JsonNode node = JSON.readTree(line); + String step = text(node, "stepText", "step", "gherkin", "phraseText"); + if (!step.isBlank()) steps.add(step); + } + } catch (IOException ignored) { + return List.of(); + } + return steps; + } + + private LayerView layer(Layer layer, Path path, int excerptLines) { + boolean present = path != null && Files.exists(path); + return new LayerView(layer, path, present, present ? readExcerpt(path, excerptLines) : ""); + } + + private static Path firstExisting(Path... paths) { + for (Path path : paths) { + if (path != null && Files.exists(path)) return path; + } + return paths.length == 0 ? null : paths[0]; + } + + private static List catalogItems(JsonNode root) { + List items = new ArrayList<>(); + if (root == null) return items; + if (root.isArray()) { + root.forEach(items::add); + return items; + } + for (String field : List.of("runs", "entries", "items")) { + JsonNode value = root.get(field); + if (value != null && value.isArray()) { + value.forEach(items::add); + return items; + } + } + if (root.has("runId") || root.has("id")) items.add(root); + return items; + } + + private static JsonNode readJson(Path file) { + try { + return JSON.readTree(file.toFile()); + } catch (IOException failure) { + throw new IllegalStateException("Could not read diagnostic JSON: " + file, failure); + } + } + + private static JsonNode readJsonIfPresent(Path file) { + if (!Files.isRegularFile(file)) return null; + try { + return JSON.readTree(file.toFile()); + } catch (IOException ignored) { + return null; + } + } + + private static String text(JsonNode node, String... fields) { + if (node == null) return ""; + for (String field : fields) { + JsonNode value = node.get(field); + if (value != null && !value.isNull() && !value.asText().isBlank()) { + return value.asText(); + } + } + return ""; + } + + private static String fileTime(Path file) { + try { + return Files.getLastModifiedTime(file).toString(); + } catch (IOException ignored) { + return ""; + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java new file mode 100644 index 00000000..28b04753 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.lease; + +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Identifies whether the current thread is acting as the human UI adapter or as + * an attached AI agent. Adapters set this around {@code WorkbenchServices} calls + * so lease checks stay in Workbench rather than Swing or MCP. + */ +public final class WorkbenchCallContext { + private static final ThreadLocal HOLDER = + ThreadLocal.withInitial(() -> WorkbenchLeaseHolder.HUMAN); + + private WorkbenchCallContext() { + } + + public static WorkbenchLeaseHolder current() { + return HOLDER.get(); + } + + public static void runAs(WorkbenchLeaseHolder holder, Runnable action) { + callAs(holder, () -> { + action.run(); + return null; + }); + } + + public static T callAs(WorkbenchLeaseHolder holder, Supplier action) { + Objects.requireNonNull(holder, "holder"); + Objects.requireNonNull(action, "action"); + WorkbenchLeaseHolder previous = HOLDER.get(); + HOLDER.set(holder); + try { + return action.get(); + } finally { + HOLDER.set(previous); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java new file mode 100644 index 00000000..7f9f3a01 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java @@ -0,0 +1,289 @@ +package tools.dscode.workbench.lease; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +/** + * Controller-owned live-control lease. Swing and MCP/HTTP adapters observe this + * state; they do not keep a second copy. + */ +public final class WorkbenchControlLease { + private static final long PERMISSION_WAIT_NS = TimeUnit.MINUTES.toNanos(30); + + private final ReentrantLock lock = new ReentrantLock(); + private final Condition permissionAnswered = lock.newCondition(); + private final List> listeners = new CopyOnWriteArrayList<>(); + + private WorkbenchLeaseHolder holder = WorkbenchLeaseHolder.HUMAN; + private String agentDisplayName = ""; + private String currentAction = ""; + private boolean uiAttached; + private WorkbenchPermissionRequest pendingPermission; + private WorkbenchPermissionDecision pendingDecision; + + public void addListener(Consumer listener) { + listeners.add(Objects.requireNonNull(listener, "listener")); + listener.accept(snapshot()); + } + + public void removeListener(Consumer listener) { + listeners.remove(listener); + } + + public void attachUi() { + lock.lock(); + try { + uiAttached = true; + if (holder == WorkbenchLeaseHolder.AGENT) { + // A visible UI always starts with the human holding the floor. + } else { + holder = WorkbenchLeaseHolder.HUMAN; + } + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public void detachUi() { + lock.lock(); + try { + uiAttached = false; + failPendingLocked("The Workbench UI closed before the permission request was answered."); + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public WorkbenchControlLeaseSnapshot snapshot() { + lock.lock(); + try { + return snapshotLocked(); + } finally { + lock.unlock(); + } + } + + public WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName) { + String name = requiredName(agentDisplayName); + lock.lock(); + try { + if (holder == WorkbenchLeaseHolder.AGENT + && !this.agentDisplayName.isBlank() + && !this.agentDisplayName.equals(name)) { + throw new IllegalStateException( + "Another AI agent already holds the Workbench control lease (" + + this.agentDisplayName + ")." + ); + } + holder = WorkbenchLeaseHolder.AGENT; + this.agentDisplayName = name; + if (currentAction.isBlank()) { + currentAction = "Waiting to work in the live scenario."; + } + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot releaseControl() { + requireAgentCaller(); + lock.lock(); + try { + if (holder != WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException("The AI agent does not currently hold the Workbench control lease."); + } + failPendingLocked("The AI agent released control before the permission request was answered."); + holder = WorkbenchLeaseHolder.HUMAN; + agentDisplayName = ""; + currentAction = ""; + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot takeControl() { + lock.lock(); + try { + failPendingLocked("The human took control before the permission request was answered."); + holder = WorkbenchLeaseHolder.HUMAN; + agentDisplayName = ""; + currentAction = ""; + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot setCurrentAction(String text) { + requireAgentCaller(); + lock.lock(); + try { + requireHolderLocked(WorkbenchLeaseHolder.AGENT); + currentAction = text == null ? "" : text.strip(); + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + /** + * Live testing and Mapping/evidence reads stay available to the lease holder. + * Call this before mutating worker, player-buffer, Mapping write, or Save paths. + */ + public void requireMutatingAccess() { + WorkbenchLeaseHolder caller = WorkbenchCallContext.current(); + lock.lock(); + try { + requireHolderLocked(caller); + } finally { + lock.unlock(); + } + } + + public boolean uiAttached() { + lock.lock(); + try { + return uiAttached; + } finally { + lock.unlock(); + } + } + + /** + * Blocks when a UI is attached until Allow, Deny, or Take control. Headless + * stdio has no banner, so the explicit tool call itself is the approval. + */ + public WorkbenchPermissionDecision awaitPermission(WorkbenchPermissionRequest request) { + Objects.requireNonNull(request, "request"); + requireAgentCaller(); + lock.lock(); + try { + requireHolderLocked(WorkbenchLeaseHolder.AGENT); + if (pendingPermission != null) { + throw new IllegalStateException("Another Workbench permission request is already pending."); + } + if (!uiAttached) { + return WorkbenchPermissionDecision.ALLOW; + } + pendingPermission = request; + pendingDecision = null; + } finally { + lock.unlock(); + } + notifyListeners(); + + lock.lock(); + try { + long remaining = PERMISSION_WAIT_NS; + while (pendingDecision == null && remaining > 0) { + remaining = permissionAnswered.awaitNanos(remaining); + } + WorkbenchPermissionDecision decision = pendingDecision; + pendingPermission = null; + pendingDecision = null; + if (decision == null) { + throw new WorkbenchPermissionCancelledException( + "Timed out waiting for a human Allow/Deny decision." + ); + } + if (decision == WorkbenchPermissionDecision.CANCELLED) { + throw new WorkbenchPermissionCancelledException(); + } + return decision; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + pendingPermission = null; + pendingDecision = null; + throw new WorkbenchPermissionCancelledException("Interrupted while waiting for Save permission."); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public void answerPermission(String requestId, boolean allow) { + lock.lock(); + try { + if (pendingPermission == null || !pendingPermission.id().equals(requestId)) { + throw new IllegalStateException("No matching Workbench permission request is pending."); + } + pendingDecision = allow ? WorkbenchPermissionDecision.ALLOW : WorkbenchPermissionDecision.DENY; + permissionAnswered.signalAll(); + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public static String newPermissionId() { + return UUID.randomUUID().toString(); + } + + private void failPendingLocked(String message) { + if (pendingPermission == null) return; + pendingDecision = WorkbenchPermissionDecision.CANCELLED; + permissionAnswered.signalAll(); + // Keep pendingPermission until the waiter clears it so the UI can drop the banner. + pendingPermission = pendingPermission; + } + + private void requireHolderLocked(WorkbenchLeaseHolder expected) { + if (holder == expected) return; + if (expected == WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException( + "The AI agent does not hold the Workbench control lease. Call workbench_request_control first." + ); + } + throw new IllegalStateException( + "An AI agent currently holds the Workbench control lease. Take control before using these controls." + ); + } + + private static void requireAgentCaller() { + if (WorkbenchCallContext.current() != WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException("Only an attached AI agent can use this control-lease action."); + } + } + + private WorkbenchControlLeaseSnapshot snapshotLocked() { + return new WorkbenchControlLeaseSnapshot( + holder, + agentDisplayName, + currentAction, + uiAttached, + pendingPermission + ); + } + + private void notifyListeners() { + WorkbenchControlLeaseSnapshot snapshot = snapshot(); + for (Consumer listener : listeners) { + try { + listener.accept(snapshot); + } catch (RuntimeException ignored) { + // Presentation listeners must not break lease transitions. + } + } + } + + private static String requiredName(String agentDisplayName) { + if (agentDisplayName == null || agentDisplayName.isBlank()) { + throw new IllegalArgumentException("Agent display name must not be blank."); + } + return agentDisplayName.strip(); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java new file mode 100644 index 00000000..4193bafa --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.lease; + +import java.util.Objects; +import java.util.Optional; + +/** Immutable view of the controller-owned control lease. */ +public record WorkbenchControlLeaseSnapshot( + WorkbenchLeaseHolder holder, + String agentDisplayName, + String currentAction, + boolean uiAttached, + WorkbenchPermissionRequest pendingPermission +) { + public WorkbenchControlLeaseSnapshot { + Objects.requireNonNull(holder, "holder"); + agentDisplayName = agentDisplayName == null ? "" : agentDisplayName; + currentAction = currentAction == null ? "" : currentAction; + } + + public boolean agentHolds() { + return holder == WorkbenchLeaseHolder.AGENT; + } + + public boolean humanHolds() { + return holder == WorkbenchLeaseHolder.HUMAN; + } + + public Optional pending() { + return Optional.ofNullable(pendingPermission); + } + + public String bannerText() { + if (!agentHolds()) return ""; + String name = agentDisplayName.isBlank() ? "AI agent" : agentDisplayName; + if (currentAction.isBlank()) { + return name + " is in control of Workbench."; + } + return name + " is in control — " + currentAction; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java new file mode 100644 index 00000000..1ae4f3cd --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java @@ -0,0 +1,7 @@ +package tools.dscode.workbench.lease; + +/** Who currently has the Workbench live-control floor. */ +public enum WorkbenchLeaseHolder { + HUMAN, + AGENT +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java new file mode 100644 index 00000000..0dc97b8a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java @@ -0,0 +1,12 @@ +package tools.dscode.workbench.lease; + +/** Raised when Take control (or release) aborts an in-flight agent permission wait. */ +public final class WorkbenchPermissionCancelledException extends IllegalStateException { + public WorkbenchPermissionCancelledException() { + super("The human took control before the permission request was answered."); + } + + public WorkbenchPermissionCancelledException(String message) { + super(message); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java new file mode 100644 index 00000000..8c8f4149 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java @@ -0,0 +1,8 @@ +package tools.dscode.workbench.lease; + +/** Human answer to a pending agent permission request. */ +public enum WorkbenchPermissionDecision { + ALLOW, + DENY, + CANCELLED +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java new file mode 100644 index 00000000..3405e24a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java @@ -0,0 +1,6 @@ +package tools.dscode.workbench.lease; + +/** Gated actions that wait for an explicit Allow/Deny when a UI is attached. */ +public enum WorkbenchPermissionKind { + SAVE +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java new file mode 100644 index 00000000..bfcdfe4a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java @@ -0,0 +1,25 @@ +package tools.dscode.workbench.lease; + +import java.nio.file.Path; +import java.util.Objects; + +/** One pending Allow/Deny request shown in the Workbench UI. */ +public record WorkbenchPermissionRequest( + String id, + WorkbenchPermissionKind kind, + String summary, + String featurePath, + String scenarioName +) { + public WorkbenchPermissionRequest { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(kind, "kind"); + summary = summary == null ? "" : summary; + featurePath = featurePath == null ? "" : featurePath; + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public Path originFile() { + return featurePath.isBlank() ? null : Path.of(featurePath); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java new file mode 100644 index 00000000..c3cea76b --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java @@ -0,0 +1,143 @@ +package tools.dscode.workbench.mapping; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Structured view of one worker-supplied NodeMap snapshot. Edits stay in this + * presentation model until they are sent through {@code mappingPut} or + * {@code mappingRestore}. + */ +public final class MappingTreeModel { + public record Property( + String key, + MappingValueCodec.ValueType type, + String text, + Object value + ) { + public Property { + key = key == null ? "" : key; + type = type == null ? MappingValueCodec.ValueType.STRING : type; + text = text == null ? "" : text; + } + } + + private final String mapReference; + private final String mapType; + private final boolean restorable; + private final List properties; + + public MappingTreeModel( + String mapReference, + String mapType, + boolean restorable, + Map values + ) { + this.mapReference = mapReference == null ? "" : mapReference; + this.mapType = mapType == null ? "" : mapType; + this.restorable = restorable; + this.properties = propertiesFrom(values); + } + + public String mapReference() { + return mapReference; + } + + public String mapType() { + return mapType; + } + + public boolean restorable() { + return restorable; + } + + public List properties() { + return List.copyOf(properties); + } + + public Map values() { + Map values = new LinkedHashMap<>(); + for (Property property : properties) { + values.put(property.key(), property.value()); + } + return values; + } + + public MappingTreeModel upsert(String key, MappingValueCodec.ValueType type, String text) { + Objects.requireNonNull(type, "type"); + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("Mapping property key must not be blank."); + } + Object value = MappingValueCodec.decode(type, text); + List updated = new ArrayList<>(); + boolean replaced = false; + for (Property property : properties) { + if (property.key().equals(key)) { + updated.add(new Property(key, type, MappingValueCodec.encode(value), value)); + replaced = true; + } else { + updated.add(property); + } + } + if (!replaced) { + updated.add(new Property(key, type, MappingValueCodec.encode(value), value)); + } + return withProperties(updated); + } + + public MappingTreeModel rename(String fromKey, String toKey) { + if (fromKey == null || fromKey.isBlank()) { + throw new IllegalArgumentException("Original Mapping key must not be blank."); + } + if (toKey == null || toKey.isBlank()) { + throw new IllegalArgumentException("New Mapping key must not be blank."); + } + List updated = new ArrayList<>(); + boolean found = false; + for (Property property : properties) { + if (property.key().equals(fromKey)) { + updated.add(new Property(toKey, property.type(), property.text(), property.value())); + found = true; + } else { + updated.add(property); + } + } + if (!found) { + throw new IllegalArgumentException("Unknown Mapping key: " + fromKey); + } + return withProperties(updated); + } + + public MappingTreeModel remove(String key) { + List updated = new ArrayList<>(); + for (Property property : properties) { + if (!property.key().equals(key)) updated.add(property); + } + return withProperties(updated); + } + + private MappingTreeModel withProperties(List properties) { + MappingTreeModel copy = new MappingTreeModel(mapReference, mapType, restorable, Map.of()); + copy.properties.clear(); + copy.properties.addAll(properties); + return copy; + } + + private static List propertiesFrom(Map values) { + List properties = new ArrayList<>(); + if (values == null) return properties; + for (Map.Entry entry : values.entrySet()) { + MappingValueCodec.ValueType type = MappingValueCodec.inferType(entry.getValue()); + properties.add(new Property( + Objects.toString(entry.getKey(), ""), + type, + MappingValueCodec.encode(entry.getValue()), + entry.getValue() + )); + } + return properties; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java new file mode 100644 index 00000000..a1d60620 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java @@ -0,0 +1,173 @@ +package tools.dscode.workbench.mapping; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Encodes Mapping property values for the existing {@code mappingPut} / + * {@code mappingRestore} services. This is presentation-side typing only; + * Workbench does not keep a second Mapping store. + */ +public final class MappingValueCodec { + public enum ValueType { + STRING, + NUMERIC, + BOOLEAN, + OBJECT_JSON, + OBJECT_XML + } + + private static final ObjectMapper JSON = new ObjectMapper(); + + private MappingValueCodec() { + } + + public static ValueType parseType(String raw) { + if (raw == null || raw.isBlank()) return ValueType.STRING; + String normalized = raw.trim().toUpperCase(Locale.ROOT) + .replace('-', '_') + .replace(' ', '_'); + return switch (normalized) { + case "STRING", "TEXT" -> ValueType.STRING; + case "NUMERIC", "NUMBER", "INTEGER", "INT", "LONG", "DOUBLE" -> ValueType.NUMERIC; + case "BOOLEAN", "BOOL" -> ValueType.BOOLEAN; + case "OBJECT_JSON", "JSON", "OBJECT_AS_JSON" -> ValueType.OBJECT_JSON; + case "OBJECT_XML", "XML", "OBJECT_AS_XML" -> ValueType.OBJECT_XML; + default -> throw new IllegalArgumentException("Unsupported Mapping value type: " + raw); + }; + } + + public static ValueType inferType(Object value) { + if (value instanceof Boolean) return ValueType.BOOLEAN; + if (value instanceof Number) return ValueType.NUMERIC; + if (value instanceof Map || value instanceof List) return ValueType.OBJECT_JSON; + return ValueType.STRING; + } + + public static Object decode(ValueType type, String text) { + Objects.requireNonNull(type, "type"); + String value = text == null ? "" : text; + return switch (type) { + case STRING -> value; + case NUMERIC -> decodeNumeric(value); + case BOOLEAN -> decodeBoolean(value); + case OBJECT_JSON -> decodeJson(value); + case OBJECT_XML -> decodeXml(value); + }; + } + + public static Object decode(String type, String text) { + return decode(parseType(type), text); + } + + public static String encode(Object value) { + if (value == null) return ""; + if (value instanceof Map || value instanceof List) { + try { + return JSON.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (JsonProcessingException failure) { + return Objects.toString(value); + } + } + return Objects.toString(value); + } + + private static Number decodeNumeric(String text) { + String value = text.strip(); + if (value.isBlank()) { + throw new IllegalArgumentException("Numeric Mapping value must not be blank."); + } + if (value.contains(".") || value.contains("e") || value.contains("E")) { + return Double.valueOf(value); + } + try { + return Long.valueOf(value); + } catch (NumberFormatException ignored) { + return Double.valueOf(value); + } + } + + private static Boolean decodeBoolean(String text) { + String value = text.strip(); + if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) { + return Boolean.valueOf(value); + } + throw new IllegalArgumentException("Boolean Mapping value must be true or false."); + } + + private static Object decodeJson(String text) { + String value = text.strip(); + if (value.isBlank()) return new LinkedHashMap(); + try { + if (value.startsWith("[")) { + return JSON.readValue(value, new TypeReference>() { }); + } + if (value.startsWith("{")) { + return JSON.readValue(value, new TypeReference>() { }); + } + return JSON.readValue(value, Object.class); + } catch (JsonProcessingException failure) { + throw new IllegalArgumentException("Invalid JSON Mapping value.", failure); + } + } + + private static Object decodeXml(String text) { + String value = text.strip(); + if (value.isBlank()) return new LinkedHashMap(); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setExpandEntityReferences(false); + Document document = factory.newDocumentBuilder() + .parse(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8))); + return elementToMap(document.getDocumentElement()); + } catch (Exception failure) { + throw new IllegalArgumentException("Invalid XML Mapping value.", failure); + } + } + + private static Map elementToMap(Element element) { + Map map = new LinkedHashMap<>(); + map.put("_name", element.getTagName()); + NamedNodeMap attributes = element.getAttributes(); + if (attributes != null && attributes.getLength() > 0) { + Map attrs = new LinkedHashMap<>(); + for (int i = 0; i < attributes.getLength(); i++) { + Node attribute = attributes.item(i); + attrs.put(attribute.getNodeName(), attribute.getNodeValue()); + } + map.put("_attributes", attrs); + } + NodeList children = element.getChildNodes(); + List nested = new ArrayList<>(); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + nested.add(elementToMap((Element) child)); + } else if (child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE) { + text.append(child.getTextContent()); + } + } + String body = text.toString().strip(); + if (!body.isEmpty()) map.put("_text", body); + if (!nested.isEmpty()) map.put("_children", nested); + return map; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java new file mode 100644 index 00000000..b28ca6d1 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java @@ -0,0 +1,231 @@ +package tools.dscode.workbench.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import tools.dscode.workbench.WorkbenchServices; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Localhost-only JSON facade over the same {@link WorkbenchMcpTools} used by + * stdio MCP. UI mode cannot share process stdout with stdio MCP, so an agent + * attaches to the visible Workbench through this endpoint. + */ +public final class WorkbenchAttachServer implements AutoCloseable { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final WorkbenchServices services; + private final Path projectRoot; + private final Path stateFile; + private final String token; + private final HttpServer http; + private final WorkbenchMcpTools tools; + private final ExecutorService executor; + private final AtomicBoolean closed = new AtomicBoolean(); + + private WorkbenchAttachServer( + WorkbenchServices services, + Path projectRoot, + Path stateFile, + String token, + HttpServer http, + WorkbenchMcpTools tools, + ExecutorService executor + ) { + this.services = services; + this.projectRoot = projectRoot; + this.stateFile = stateFile; + this.token = token; + this.http = http; + this.tools = tools; + this.executor = executor; + } + + public static WorkbenchAttachServer start(WorkbenchServices services, Path projectRoot) { + Objects.requireNonNull(services, "services"); + Path root = projectRoot.toAbsolutePath().normalize(); + try { + HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + String token = newToken(); + WorkbenchMcpTools tools = new WorkbenchMcpTools(services, JSON); + Path stateFile = attachStateFile(root); + ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "pickleball-workbench-attach"); + thread.setDaemon(true); + return thread; + }); + WorkbenchAttachServer server = new WorkbenchAttachServer( + services, root, stateFile, token, http, tools, executor + ); + http.createContext("/health", server::health); + http.createContext("/lease", server::lease); + http.createContext("/player", server::player); + http.createContext("/tools", server::tools); + http.setExecutor(executor); + http.start(); + server.writeStateFile(); + return server; + } catch (IOException failure) { + throw new IllegalStateException("Could not start the Workbench agent-attach endpoint.", failure); + } + } + + public static Path attachStateFile(Path projectRoot) { + return projectRoot.resolve(".pickleball").resolve("workbench").resolve("attach.json"); + } + + public String url() { + return "http://127.0.0.1:" + http.getAddress().getPort(); + } + + public String token() { + return token; + } + + public Path stateFile() { + return stateFile; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + http.stop(0); + executor.shutdownNow(); + try { + Files.deleteIfExists(stateFile); + } catch (IOException ignored) { + // Disposable attach state. + } + } + + private void health(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, Map.of( + "status", "ok", + "url", url(), + "pid", ProcessHandle.current().pid() + )); + } + + private void lease(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, services.controlLeaseSnapshot()); + } + + private void player(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, services.playerState()); + } + + private void tools(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + String path = exchange.getRequestURI().getPath(); + if ("/tools".equals(path) && "GET".equals(exchange.getRequestMethod())) { + send(exchange, 200, Map.of("tools", tools.names())); + return; + } + if (!path.startsWith("/tools/") || path.length() <= "/tools/".length()) { + send(exchange, 404, Map.of("error", "Unknown attach path")); + return; + } + if (!"POST".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "POST a JSON argument object to invoke a tool")); + return; + } + String name = path.substring("/tools/".length()); + Map arguments = readJsonObject(exchange.getRequestBody()); + try { + Object value = tools.call(name, arguments); + send(exchange, 200, value); + } catch (RuntimeException failure) { + send(exchange, 400, Map.of( + "error", failure.getClass().getSimpleName(), + "message", failure.getMessage() == null ? failure.toString() : failure.getMessage() + )); + } + } + + private boolean authorized(HttpExchange exchange) throws IOException { + String header = firstHeader(exchange.getRequestHeaders(), "Authorization"); + String tokenHeader = firstHeader(exchange.getRequestHeaders(), "X-Workbench-Token"); + String presented = tokenHeader; + if (header != null && header.regionMatches(true, 0, "Bearer ", 0, 7)) { + presented = header.substring(7).strip(); + } + if (token.equals(presented)) return true; + send(exchange, 401, Map.of("error", "Missing or invalid Workbench attach token")); + return false; + } + + private void writeStateFile() throws IOException { + Files.createDirectories(stateFile.getParent()); + Map payload = new LinkedHashMap<>(); + payload.put("url", url()); + payload.put("token", token); + payload.put("pid", ProcessHandle.current().pid()); + payload.put("project", projectRoot.toString()); + payload.put("mode", "ui-attach"); + payload.put("bind", "127.0.0.1"); + Files.writeString(stateFile, JSON.writerWithDefaultPrettyPrinter().writeValueAsString(payload)); + } + + private static String firstHeader(Headers headers, String name) { + if (headers == null) return null; + String value = headers.getFirst(name); + return value == null || value.isBlank() ? null : value.strip(); + } + + @SuppressWarnings("unchecked") + private static Map readJsonObject(InputStream input) throws IOException { + byte[] bytes = input.readAllBytes(); + if (bytes.length == 0) return Map.of(); + Object value = JSON.readValue(bytes, Object.class); + if (value instanceof Map map) { + return (Map) map; + } + throw new IllegalArgumentException("Tool arguments must be a JSON object."); + } + + private static void send(HttpExchange exchange, int status, Object body) throws IOException { + byte[] bytes = JSON.writeValueAsBytes(body); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static String newToken() { + byte[] bytes = new byte[24]; + RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index 8b5423e2..a8b6fa98 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java @@ -3,8 +3,10 @@ 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 tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -16,69 +18,124 @@ final class WorkbenchMcpTools { private final WorkbenchServices services; private final ObjectMapper json; + private final Map tools = new LinkedHashMap<>(); WorkbenchMcpTools(WorkbenchServices services, ObjectMapper json) { this.services = services; this.json = json; + register(); } List specifications() { - List tools = new ArrayList<>(); - - tools.add(tool("workbench_sync", "Synchronize the selected consumer project.", schema(Map.of()), - args -> services.synchronize())); - tools.add(tool("workbench_sync_status", "Read the current Workbench synchronization manifest.", schema(Map.of()), - args -> services.synchronizationStatus())); - tools.add(tool("workbench_worker_start", "Start the persistent interactive consumer worker.", schema(Map.of()), - args -> services.startWorker())); - tools.add(tool("workbench_worker_restart", "Restart the worker in a fresh JVM without rebuilding.", schema(Map.of()), - args -> services.restartWorker())); - tools.add(tool("workbench_worker_stop", "Stop the interactive worker cleanly.", schema(Map.of()), - args -> services.stopWorker())); - tools.add(tool("workbench_worker_status", "Read the current interactive worker status.", schema(Map.of()), - args -> services.workerStatus())); - - tools.add(tool("workbench_execute_step", "Execute raw Gherkin in the paused live scenario.", + List specifications = new ArrayList<>(); + for (ToolBinding binding : tools.values()) { + specifications.add(specification(binding)); + } + return List.copyOf(specifications); + } + + List names() { + return List.copyOf(tools.keySet()); + } + + Object call(String name, Map arguments) { + ToolBinding binding = tools.get(name); + if (binding == null) { + throw new IllegalArgumentException("Unknown Workbench tool: " + name); + } + return WorkbenchCallContext.callAs( + WorkbenchLeaseHolder.AGENT, + () -> binding.action.apply(arguments == null ? Map.of() : arguments) + ); + } + + private void register() { + add("workbench_sync", "Synchronize the selected consumer project.", schema(Map.of()), + args -> services.synchronize()); + add("workbench_sync_status", "Read the current Workbench synchronization manifest.", schema(Map.of()), + args -> services.synchronizationStatus()); + add("workbench_worker_start", "Start the persistent interactive consumer worker.", schema(Map.of()), + args -> services.startWorker()); + add("workbench_worker_restart", "Restart the worker in a fresh JVM without rebuilding.", schema(Map.of()), + args -> services.restartWorker()); + add("workbench_worker_stop", "Stop the interactive worker cleanly.", schema(Map.of()), + args -> services.stopWorker()); + add("workbench_worker_status", "Read the current interactive worker status.", schema(Map.of()), + args -> services.workerStatus()); + + add("workbench_request_control", + "Request the Workbench live-control lease so this agent can test the live scenario while the human watches.", + schema(Map.of("agentName", stringProperty("Display name shown in the Workbench banner.")), "agentName"), + args -> services.requestControl(text(args, "agentName"))); + add("workbench_release_control", + "Release the Workbench live-control lease back to the human.", + schema(Map.of()), + args -> services.releaseControl()); + add("workbench_set_current_action", + "Update the watched-agent banner with what this agent is currently doing.", + schema(Map.of("text", stringProperty("Short action text shown in the UI banner.")), "text"), + args -> services.setCurrentAction(text(args, "text"))); + add("workbench_control_lease", + "Read the current Workbench control-lease holder, banner action, and pending permission.", + schema(Map.of()), + args -> services.controlLeaseSnapshot()); + add("workbench_player_state", + "Read the shared live scenario buffer, playhead, player state, and originating feature path if any.", + schema(Map.of()), + args -> services.playerState()); + add("workbench_player_replace_document", + "Replace the live session buffer. Does not write the original .feature file.", + schema(Map.of("text", stringProperty("Full live Gherkin document.")), "text"), + args -> { + services.replaceLiveDocument(text(args, "text").lines().toList()); + return services.playerState(); + }); + add("workbench_request_save", + "Ask to copy the live scenario into the original .feature file. With a UI attached this waits for Allow/Deny and never writes on deny.", + schema(Map.of()), + args -> services.requestSave()); + + add("workbench_execute_step", "Execute raw Gherkin in the paused live scenario.", schema(Map.of( "text", stringProperty("Gherkin step text."), "argument", stringProperty("Optional DocString-style argument text.") ), "text"), - args -> services.executeStep(text(args, "text"), optionalText(args, "argument")))); + args -> services.executeStep(text(args, "text"), optionalText(args, "argument"))); - tools.add(tool("workbench_mapping_get", "Read one value from a Pickleball Mapping.", + add("workbench_mapping_get", "Read one value from a Pickleball Mapping.", schema(Map.of( "mapReference", stringProperty("Mapping reference."), "key", stringProperty("Mapping key.") ), "mapReference", "key"), - args -> services.mappingGet(text(args, "mapReference"), text(args, "key")))); - tools.add(tool("workbench_mapping_put", "Write one value into a Pickleball Mapping.", + args -> services.mappingGet(text(args, "mapReference"), text(args, "key"))); + add("workbench_mapping_put", "Write one value into a Pickleball Mapping.", schema(Map.of( "mapReference", stringProperty("Mapping reference."), "key", stringProperty("Mapping key."), "value", Map.of("description", "JSON-compatible value to store.") ), "mapReference", "key", "value"), - args -> services.mappingPut(text(args, "mapReference"), text(args, "key"), args.get("value")))); - tools.add(tool("workbench_mapping_resolve", "Resolve Pickleball mapping/template references in text.", + args -> services.mappingPut(text(args, "mapReference"), text(args, "key"), args.get("value"))); + add("workbench_mapping_resolve", "Resolve Pickleball mapping/template references in text.", schema(Map.of("input", stringProperty("Text to resolve.")), "input"), - args -> services.mappingResolve(text(args, "input")))); - tools.add(tool("workbench_mapping_snapshot", "Snapshot one Mapping for later restoration.", + args -> services.mappingResolve(text(args, "input"))); + add("workbench_mapping_snapshot", "Snapshot one Mapping for later restoration.", schema(Map.of("mapReference", stringProperty("Mapping reference.")), "mapReference"), - args -> services.mappingSnapshot(text(args, "mapReference")))); - tools.add(tool("workbench_mapping_restore", "Restore a previously returned Mapping snapshot.", + args -> services.mappingSnapshot(text(args, "mapReference"))); + add("workbench_mapping_restore", "Restore a previously returned Mapping snapshot.", schema(Map.of("snapshot", objectProperty("Snapshot returned by workbench_mapping_snapshot.")), "snapshot"), - args -> services.mappingRestore(json.convertValue(args.get("snapshot"), ControlBridgeMappingSnapshot.class)))); + args -> services.mappingRestore(json.convertValue(args.get("snapshot"), ControlBridgeMappingSnapshot.class))); - tools.add(tool("workbench_events", "Read semantic runtime events for the active scenario.", + add("workbench_events", "Read semantic runtime events for the active scenario.", schema(Map.of( "afterSequence", integerProperty("Return events after this sequence number."), "limit", integerProperty("Maximum events to return.") )), - args -> services.events(longValue(args, "afterSequence"), integer(args, "limit")))); - tools.add(tool("workbench_browser_page", "Read current browser page evidence.", schema(Map.of()), - args -> services.browserPage())); - tools.add(tool("workbench_browser_screenshot", "Capture current browser screenshot evidence.", schema(Map.of()), - args -> services.browserScreenshot())); - tools.add(tool("workbench_element_inspect", "Inspect browser elements using Pickleball element vocabulary.", + args -> services.events(longValue(args, "afterSequence"), integer(args, "limit"))); + add("workbench_browser_page", "Read current browser page evidence.", schema(Map.of()), + args -> services.browserPage()); + add("workbench_browser_screenshot", "Capture current browser screenshot evidence.", schema(Map.of()), + args -> services.browserScreenshot()); + add("workbench_element_inspect", "Inspect browser elements using Pickleball element vocabulary.", schema(Map.of( "category", stringProperty("Optional Pickleball element category."), "text", stringProperty("Optional visible/context text."), @@ -90,14 +147,14 @@ List specifications() { optionalText(args, "text"), optionalText(args, "operation"), integer(args, "maxElements") - ))); - tools.add(tool("workbench_service_call", "Execute an existing Pickleball service-call selector and return evidence.", + )); + add("workbench_service_call", "Execute an existing Pickleball service-call selector and return evidence.", schema(Map.of("selector", stringProperty("Existing service-call selector, for example %health-full-url.")), "selector"), - args -> services.serviceCall(text(args, "selector")))); + args -> services.serviceCall(text(args, "selector"))); - tools.add(tool("workbench_breakpoint_list", "List semantic breakpoints.", schema(Map.of()), - args -> services.breakpoints())); - tools.add(tool("workbench_breakpoint_add", "Add a semantic runtime breakpoint.", + add("workbench_breakpoint_list", "List semantic breakpoints.", schema(Map.of()), + args -> services.breakpoints()); + add("workbench_breakpoint_add", "Add a semantic runtime breakpoint.", schema(Map.of( "hook", stringProperty("Control hook name, for example BEFORE_STEP."), "signatureContains", stringProperty("Optional signature substring filter."), @@ -113,16 +170,16 @@ List specifications() { optionalText(args, "phraseContains"), bool(args, "oneShot", false), integer(args, "leaseSeconds") - ))); - tools.add(tool("workbench_breakpoint_remove", "Remove one semantic breakpoint.", + )); + add("workbench_breakpoint_remove", "Remove one semantic breakpoint.", schema(Map.of("breakpointId", stringProperty("Breakpoint id.")), "breakpointId"), - args -> Map.of("removed", services.removeBreakpoint(text(args, "breakpointId"))))); - tools.add(tool("workbench_breakpoint_clear", "Clear all semantic breakpoints.", schema(Map.of()), - args -> Map.of("removed", services.clearBreakpoints()))); + args -> Map.of("removed", services.removeBreakpoint(text(args, "breakpointId")))); + add("workbench_breakpoint_clear", "Clear all semantic breakpoints.", schema(Map.of()), + args -> Map.of("removed", services.clearBreakpoints())); - tools.add(tool("workbench_step_override_list", "List Step Overrides in the active live scenario.", schema(Map.of()), - args -> services.stepOverrides())); - tools.add(tool("workbench_step_override_compile", "Compile and install a scenario-scoped REPLACE/REGEX Step Override in the worker.", + add("workbench_step_override_list", "List Step Overrides in the active live scenario.", schema(Map.of()), + args -> services.stepOverrides()); + add("workbench_step_override_compile", "Compile and install a scenario-scoped REPLACE/REGEX Step Override in the worker.", schema(Map.of( "id", stringProperty("Stable override id; recompiling the id replaces it."), "regex", stringProperty("Regular expression matched before ordinary Cucumber glue."), @@ -130,37 +187,62 @@ List specifications() { ), "id", "regex", "source"), args -> services.compileStepOverride( text(args, "id"), text(args, "regex"), text(args, "source") - ))); - tools.add(tool("workbench_step_override_remove", "Remove one Step Override from the active scenario.", + )); + add("workbench_step_override_remove", "Remove one Step Override from the active scenario.", schema(Map.of("id", stringProperty("Override id.")), "id"), - args -> Map.of("removed", services.removeStepOverride(text(args, "id"))))); - tools.add(tool("workbench_step_override_clear", "Clear all Step Overrides from the active scenario.", schema(Map.of()), - args -> Map.of("removed", services.clearStepOverrides()))); + args -> Map.of("removed", services.removeStepOverride(text(args, "id")))); + add("workbench_step_override_clear", "Clear all Step Overrides from the active scenario.", schema(Map.of()), + args -> Map.of("removed", services.clearStepOverrides())); - return List.copyOf(tools); + add("workbench_diagnostic_catalog", + "Read reports/diagnostic-runs/run-catalog.json as sparse JSON. Do not glob the diagnostic tree.", + schema(Map.of()), + args -> services.diagnosticCatalog()); + add("workbench_diagnostic_run", + "Read one run's run-index.json and clusters.json. Does not return events, traces, or screenshots.", + schema(Map.of("runId", stringProperty("Diagnostic run directory name from the catalog.")), "runId"), + args -> services.diagnosticRun(text(args, "runId"))); + add("workbench_diagnostic_summary", + "Read one scenario summary.json. Does not return events.jsonl, traces, or PNG bytes.", + schema(Map.of( + "runId", stringProperty("Diagnostic run directory name from the catalog."), + "scenarioId", stringProperty("Scenario directory name under that run.") + ), "runId", "scenarioId"), + args -> services.diagnosticScenarioSummary(text(args, "runId"), text(args, "scenarioId"))); + add("workbench_investigation_emit", + "Write .pickleball/investigations//{investigation.json,report.html} from investigation JSON. Returns the relative report.html path only. Does not copy the diagnostic pack or embed PNG bytes.", + schema(Map.of( + "investigation", Map.of( + "type", "object", + "description", "Investigation JSON object. Source of truth written to investigation.json.", + "additionalProperties", true + ) + ), "investigation"), + args -> services.emitInvestigation(investigationObject(args.get("investigation")))); } - private McpServerFeatures.SyncToolSpecification tool( + private void add( String name, String description, Map inputSchema, Function, Object> action ) { - McpSchema.Tool tool = McpSchema.Tool.builder(name, inputSchema) - .description(description) + tools.put(name, new ToolBinding(name, description, inputSchema, action)); + } + + private McpServerFeatures.SyncToolSpecification specification(ToolBinding binding) { + McpSchema.Tool tool = McpSchema.Tool.builder(binding.name, binding.inputSchema) + .description(binding.description) .build(); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) - .callHandler((exchange, request) -> invoke(action, request.arguments())) + .callHandler((exchange, request) -> invoke(binding.name, request.arguments())) .build(); } - private McpSchema.CallToolResult invoke( - Function, Object> action, - Map arguments - ) { + private McpSchema.CallToolResult invoke(String name, Map arguments) { try { - Object value = action.apply(arguments == null ? Map.of() : arguments); + Object value = call(name, arguments); return result(value, false); } catch (RuntimeException failure) { return result(Map.of( @@ -234,4 +316,34 @@ private static boolean bool(Map args, String name, boolean defau Object value = args.get(name); return value instanceof Boolean bool ? bool : defaultValue; } + + @SuppressWarnings("unchecked") + private Map investigationObject(Object value) { + if (value == null) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + Object parsed = value; + if (parsed instanceof String text) { + if (text.isBlank()) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + try { + parsed = json.readValue(text, LinkedHashMap.class); + } catch (Exception failure) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + } + if (!(parsed instanceof Map)) { + throw new IllegalArgumentException("investigation must be a JSON object."); + } + return json.convertValue(parsed, LinkedHashMap.class); + } + + private record ToolBinding( + String name, + String description, + Map inputSchema, + Function, Object> action + ) { + } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java new file mode 100644 index 00000000..7302d866 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java @@ -0,0 +1,261 @@ +package tools.dscode.workbench.player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Nested block view of a live Gherkin buffer. + * + *

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

+ */ +public final class GherkinBlockDocument { + public record Block(long id, String text, int nestLevel, List children) { + public Block { + Objects.requireNonNull(text, "text"); + children = List.copyOf(children == null ? List.of() : children); + } + + public boolean structural() { + String trimmed = stripNestPrefix(text); + return startsWithAny(trimmed, + "Feature:", "Rule:", "Background:", "Scenario:", "Scenario Outline:", "Examples:"); + } + + public boolean nestable() { + return !structural() && !text.strip().startsWith("#") && !text.isBlank(); + } + } + + private final List roots; + + public GherkinBlockDocument(List roots) { + this.roots = List.copyOf(roots == null ? List.of() : roots); + } + + public static GherkinBlockDocument fromPlayer(LiveScenarioPlayer player) { + return fromLines(player.lines()); + } + + public static GherkinBlockDocument fromLines(List lines) { + List parsed = new ArrayList<>(); + for (LiveScenarioPlayer.Line line : lines) { + parsed.add(new Parsed(line.id(), line.text(), nestLevel(line.text()), stripNestPrefix(line.text()))); + } + return new GherkinBlockDocument(buildTree(parsed)); + } + + public static GherkinBlockDocument fromTexts(List texts) { + List lines = new ArrayList<>(); + long id = 1; + for (String text : texts) { + String value = text == null ? "" : text; + lines.add(new LiveScenarioPlayer.Line(id++, value, LiveScenarioPlayer.LineType.TEXT)); + } + return fromLines(lines); + } + + public List roots() { + return roots; + } + + public List toLines() { + List lines = new ArrayList<>(); + write(roots, 0, lines); + return List.copyOf(lines); + } + + public void applyTo(LiveScenarioPlayer player) { + player.replaceDocument(toLines()); + } + + public Optional find(long id) { + return find(roots, id); + } + + public GherkinBlockDocument updateText(long id, String text) { + String value = text == null ? "" : text; + return new GherkinBlockDocument(map(roots, id, block -> + new Block(block.id(), value, block.nestLevel(), block.children()))); + } + + /** + * Moves {@code id} so it becomes a child of {@code parentId} (or a root + * when {@code parentId} is empty) at {@code index}. Nested IF/ELSE and + * nested steps snap as parent/child this way. + */ + public GherkinBlockDocument move(long id, OptionalLong parentId, int index) { + Block moving = find(id).orElseThrow(() -> new IllegalArgumentException("Unknown block id: " + id)); + if (parentId.isPresent() && contains(moving, parentId.getAsLong())) { + throw new IllegalArgumentException("Cannot nest a block inside itself."); + } + List without = remove(roots, id); + Block relocated = new Block(moving.id(), moving.text(), 0, moving.children()); + List inserted = insert(without, parentId, Math.max(0, index), relocated); + return new GherkinBlockDocument(inserted); + } + + public Optional playheadBlock(LiveScenarioPlayer player) { + if (player.playheadId().isEmpty()) return Optional.empty(); + return find(player.playheadId().getAsLong()); + } + + private static void write(List blocks, int level, List lines) { + for (Block block : blocks) { + lines.add(applyNest(block.text(), level)); + write(block.children(), level + 1, lines); + } + } + + private static List buildTree(List parsed) { + class Mutable { + final long id; + final String text; + final int level; + final List children = new ArrayList<>(); + + Mutable(long id, String text, int level) { + this.id = id; + this.text = text; + this.level = level; + } + + Block freeze() { + return new Block(id, text, level, children.stream().map(Mutable::freeze).toList()); + } + } + + List roots = new ArrayList<>(); + List stack = new ArrayList<>(); + for (Parsed item : parsed) { + Mutable created = new Mutable(item.id(), item.body(), item.level()); + while (!stack.isEmpty() && stack.getLast().level >= item.level()) { + stack.removeLast(); + } + if (stack.isEmpty()) { + roots.add(created); + } else { + stack.getLast().children.add(created); + } + stack.add(created); + } + return roots.stream().map(Mutable::freeze).toList(); + } + + private static List map(List blocks, long id, java.util.function.Function mapper) { + List updated = new ArrayList<>(blocks.size()); + for (Block block : blocks) { + List children = map(block.children(), id, mapper); + Block current = new Block(block.id(), block.text(), block.nestLevel(), children); + updated.add(current.id() == id ? mapper.apply(current) : current); + } + return updated; + } + + private static List remove(List blocks, long id) { + List updated = new ArrayList<>(); + for (Block block : blocks) { + if (block.id() == id) continue; + updated.add(new Block(block.id(), block.text(), block.nestLevel(), remove(block.children(), id))); + } + return updated; + } + + private static List insert(List blocks, OptionalLong parentId, int index, Block moving) { + if (parentId.isEmpty()) { + List roots = new ArrayList<>(blocks); + roots.add(Math.min(index, roots.size()), moving); + return roots; + } + List updated = new ArrayList<>(blocks.size()); + for (Block block : blocks) { + if (block.id() == parentId.getAsLong()) { + List children = new ArrayList<>(block.children()); + children.add(Math.min(index, children.size()), moving); + updated.add(new Block(block.id(), block.text(), block.nestLevel(), children)); + } else { + updated.add(new Block( + block.id(), + block.text(), + block.nestLevel(), + insert(block.children(), parentId, index, moving) + )); + } + } + return updated; + } + + private static Optional find(List blocks, long id) { + for (Block block : blocks) { + if (block.id() == id) return Optional.of(block); + Optional nested = find(block.children(), id); + if (nested.isPresent()) return nested; + } + return Optional.empty(); + } + + private static boolean contains(Block block, long id) { + if (block.id() == id) return true; + for (Block child : block.children()) { + if (contains(child, id)) return true; + } + return false; + } + + static int nestLevel(String text) { + String trimmed = text == null ? "" : text.stripLeading(); + int level = 0; + while (trimmed.startsWith(":")) { + level++; + trimmed = trimmed.substring(1).stripLeading(); + } + return level; + } + + static String stripNestPrefix(String text) { + if (text == null) return ""; + String indent = leadingWhitespace(text); + String trimmed = text.stripLeading(); + while (trimmed.startsWith(":")) { + trimmed = trimmed.substring(1).stripLeading(); + } + if (text.isBlank()) return text; + if (indent.isEmpty()) return trimmed; + return trimmed; + } + + static String applyNest(String body, int level) { + String content = stripNestPrefix(body); + if (content.isBlank() || content.startsWith("#")) { + return body == null ? "" : body; + } + if (level <= 0) { + return body != null && body.startsWith(" ") ? preserveIndent(body, content) : content; + } + return " " + ":".repeat(level) + " " + content; + } + + private static String preserveIndent(String original, String content) { + return leadingWhitespace(original) + content; + } + + private static String leadingWhitespace(String text) { + if (text == null) return ""; + int i = 0; + while (i < text.length() && Character.isWhitespace(text.charAt(i))) i++; + return text.substring(0, i); + } + + private static boolean startsWithAny(String value, String... prefixes) { + for (String prefix : prefixes) { + if (value.startsWith(prefix)) return true; + } + return false; + } + + private record Parsed(long id, String original, int level, String body) { } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java new file mode 100644 index 00000000..d0b0f980 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveEditorView.java @@ -0,0 +1,66 @@ +package tools.dscode.workbench.player; + +/** + * Headless Text vs Blocks presentation choice for the live scenario editor. + * + *

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

+ */ +public final class LiveEditorView { + public enum Mode { + TEXT, + BLOCKS + } + + private final boolean blocksAvailable; + private Mode mode; + + public LiveEditorView(boolean blocksAvailable) { + this.blocksAvailable = blocksAvailable; + this.mode = blocksAvailable ? Mode.BLOCKS : Mode.TEXT; + } + + public static LiveEditorView blocksAvailable() { + return new LiveEditorView(true); + } + + public static LiveEditorView blocksUnavailable() { + return new LiveEditorView(false); + } + + public boolean canShowBlocks() { + return blocksAvailable; + } + + public Mode mode() { + return mode; + } + + public boolean showingBlocks() { + return mode == Mode.BLOCKS; + } + + /** + * Selects Text or Blocks. Requests for Blocks when the WebView host is + * unavailable are ignored and return {@code false}; the view stays Text. + */ + public boolean setMode(Mode requested) { + Mode next = requested == null ? mode : requested; + if (next == Mode.BLOCKS && !blocksAvailable) { + mode = Mode.TEXT; + return false; + } + mode = next; + return true; + } + + public boolean showText() { + return setMode(Mode.TEXT); + } + + public boolean showBlocks() { + return setMode(Mode.BLOCKS); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java new file mode 100644 index 00000000..b959e3c7 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java @@ -0,0 +1,119 @@ +package tools.dscode.workbench.player; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Copies the live session buffer into the originating scenario of the original + * {@code .feature} file. Demo buffers have no save path. Picker load never writes. + */ +public final class LiveFeatureSave { + private LiveFeatureSave() { + } + + public static WorkbenchSavePreview preview(LivePlaybackCoordinator playback) { + Objects.requireNonNull(playback, "playback"); + ScenarioOrigin origin = playback.origin(); + if (!origin.savable()) { + return WorkbenchSavePreview.unsavable( + "The default demo is session-only and has no original .feature file to write." + ); + } + List body = scenarioBody(playback.player().documentText()); + String fileName = origin.file().getFileName().toString(); + String scenario = origin.scenarioName().isBlank() ? "(unnamed scenario)" : origin.scenarioName(); + return new WorkbenchSavePreview( + true, + origin.file(), + origin.scenarioName(), + "Copy these live steps into file " + fileName + " / scenario " + scenario + "?", + body + ); + } + + public static WorkbenchSaveResult write(LivePlaybackCoordinator playback) { + WorkbenchSavePreview preview = preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + Path file = preview.featurePath(); + ScenarioOrigin origin = playback.origin(); + try { + List original = Files.exists(file) + ? Files.readAllLines(file, StandardCharsets.UTF_8) + : new ArrayList<>(); + List replacement = preview.liveScenarioLines(); + List rewritten = splice(original, origin.startLine(), origin.endLine(), replacement); + String newline = detectNewline(file); + Files.writeString(file, join(rewritten, newline), StandardCharsets.UTF_8); + int newEnd = origin.startLine() + replacement.size() - 1; + playback.updateOrigin(origin.withEndLine(Math.max(origin.startLine(), newEnd))); + return WorkbenchSaveResult.written(file, origin.scenarioName()); + } catch (IOException failure) { + throw new IllegalStateException("Could not write the originating feature file: " + file, failure); + } + } + + static List scenarioBody(String documentText) { + List lines = splitPreserve(documentText); + int start = 0; + for (int i = 0; i < lines.size(); i++) { + String trimmed = lines.get(i).strip(); + if (trimmed.startsWith("Scenario:") || trimmed.startsWith("Scenario Outline:")) { + start = i; + break; + } + } + List body = new ArrayList<>(lines.subList(start, lines.size())); + while (!body.isEmpty() && body.getLast().isBlank()) { + body.removeLast(); + } + return body; + } + + static List splice(List original, int startLine, int endLine, List replacement) { + List rewritten = new ArrayList<>(); + int start = Math.max(1, startLine); + int end = Math.max(start, endLine); + int size = original.size(); + for (int i = 1; i < start && i <= size; i++) { + rewritten.add(original.get(i - 1)); + } + rewritten.addAll(replacement); + for (int i = end + 1; i <= size; i++) { + rewritten.add(original.get(i - 1)); + } + return rewritten; + } + + private static List splitPreserve(String documentText) { + if (documentText == null || documentText.isEmpty()) return new ArrayList<>(); + String normalized = documentText.replace("\r\n", "\n").replace('\r', '\n'); + String[] parts = normalized.split("\n", -1); + List lines = new ArrayList<>(parts.length); + for (String part : parts) { + lines.add(part); + } + if (!lines.isEmpty() && lines.getLast().isEmpty()) { + lines.removeLast(); + } + return lines; + } + + private static String detectNewline(Path file) throws IOException { + if (!Files.exists(file)) return System.lineSeparator(); + String raw = Files.readString(file, StandardCharsets.UTF_8); + if (raw.contains("\r\n")) return "\r\n"; + if (raw.contains("\n")) return "\n"; + return System.lineSeparator(); + } + + private static String join(List lines, String newline) { + return String.join(newline, lines) + newline; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java new file mode 100644 index 00000000..07b9eba0 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java @@ -0,0 +1,129 @@ +package tools.dscode.workbench.player; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Headless play-header / block-buffer coordinator. Swing and WebView adapters + * report selection, playhead, and document changes here; execution still goes + * through {@code WorkbenchServices.executeStep}. + */ +public final class LivePlaybackCoordinator { + private final LiveScenarioPlayer player; + private ScenarioOrigin origin = ScenarioOrigin.none(); + + public LivePlaybackCoordinator(LiveScenarioPlayer player) { + this.player = Objects.requireNonNull(player, "player"); + } + + public LiveScenarioPlayer player() { + return player; + } + + public ScenarioOrigin origin() { + return origin; + } + + public Optional originFile() { + return origin.originFile(); + } + + public void clearOrigin() { + origin = ScenarioOrigin.none(); + } + + public void updateOrigin(ScenarioOrigin origin) { + this.origin = origin == null ? ScenarioOrigin.none() : origin; + } + + public void loadDefaultDemo() { + origin = ScenarioOrigin.none(); + player.loadDocument(LiveScenarioPlayer.DEFAULT_DEMO_SCENARIO); + } + + public void loadScenario(List lines, java.nio.file.Path originFile) { + loadScenario(lines, originFile, "", 0, 0); + } + + public void loadScenario( + List lines, + java.nio.file.Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + origin = originFile == null + ? ScenarioOrigin.none() + : new ScenarioOrigin(originFile, scenarioName, startLine, endLine); + player.loadDocument(lines); + } + + public GherkinBlockDocument blocks() { + return GherkinBlockDocument.fromPlayer(player); + } + + public void replaceFromBlocks(GherkinBlockDocument document) { + Objects.requireNonNull(document, "document").applyTo(player); + } + + public void replaceFromLines(List lines) { + player.replaceDocument(lines); + } + + public void seek(long lineId) { + player.clickLine(lineId); + } + + public void playFromStart() { + player.startFromBeginning(); + } + + public void playFromHere() { + player.startFromSelectedStep(); + } + + public void pause() { + player.pause(); + } + + public void stop() { + player.stop(); + } + + public void stepOnly() { + player.pauseForIsolatedExecution(); + } + + public LiveScenarioPlayer.Line insertAndMaybeContinue(String text) { + return player.insertStep(text); + } + + public boolean waitingForStep() { + return player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP; + } + + public boolean running() { + return player.state() == LiveScenarioPlayer.State.RUNNING; + } + + /** + * Single owner for playhead follow after a worker {@code executeStep}. + * Advances only while {@link LiveScenarioPlayer.State#RUNNING} and only when + * the executed text is the current next step. Isolated Step Only pauses first, + * so it does not move the playhead. Attached-agent {@code execute_step} uses + * this same follow while a UI Play run is in progress. + */ + public void followExecutedStep(String text, boolean successful) { + LiveScenarioPlayer.Line next = player.nextStep().orElse(null); + if (next == null || text == null || !next.text().equals(text) + || player.state() != LiveScenarioPlayer.State.RUNNING) { + return; + } + if (successful) { + player.markCurrentStepExecuted(next.id()); + } else { + player.markCurrentStepFailed(next.id()); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java new file mode 100644 index 00000000..d3af2079 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java @@ -0,0 +1,465 @@ +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; +import java.util.stream.Collectors; + +/** + * Headless presentation model for the Workbench live scenario buffer. + * + *

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 { + STOPPED, + PAUSED, + RUNNING, + WAITING_FOR_STEP + } + + public enum LineType { + STRUCTURE, + STEP, + COMMENT, + BLANK, + TEXT + } + + public record Line(long id, String text, LineType type) { + public Line { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(type, "type"); + } + + public boolean executable() { + return type == LineType.STEP; + } + } + + /** + * 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; + + public LiveScenarioPlayer(List initialLines) { + if (initialLines != null) { + for (String text : initialLines) { + addInitialLine(text == null ? "" : text); + } + } + initializeCursors(); + } + + /** Default live buffer: a small browser demo against the consumer local test site. */ + public static LiveScenarioPlayer interactiveBuffer() { + return new LiveScenarioPlayer(DEFAULT_DEMO_SCENARIO); + } + + /** + * Replaces the session buffer with a newly loaded scenario and returns to + * {@link State#STOPPED}. Used by the feature/scenario picker. The origin + * file is presentation metadata only; this model never writes {@code .feature} + * files. + */ + public void loadDocument(List texts) { + lines.clear(); + nextId = 1; + selectedId = null; + playheadId = null; + lastExecutedId = null; + executionIndex = 0; + state = State.STOPPED; + List incoming = texts == null || texts.isEmpty() ? List.of("") : texts; + for (String text : incoming) { + addInitialLine(text == null ? "" : text); + } + initializeCursors(); + } + + public List lines() { + return List.copyOf(lines); + } + + public String documentText() { + return lines.stream().map(Line::text).collect(Collectors.joining("\n")); + } + + public State state() { + return state; + } + + public OptionalLong selectedId() { + return selectedId == null ? OptionalLong.empty() : OptionalLong.of(selectedId); + } + + public Optional selectedLine() { + return line(selectedId); + } + + public OptionalLong playheadId() { + return playheadId == null ? OptionalLong.empty() : OptionalLong.of(playheadId); + } + + public Optional playheadLine() { + return line(playheadId); + } + + public Optional nextStep() { + if (executionIndex >= lines.size()) return Optional.empty(); + Line line = lines.get(executionIndex); + 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; + } + + public void clearSelection() { + selectedId = null; + } + + /** 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().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. 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 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 && executionIndex < lines.size()) { + executionIndex++; + } + return inserted; + } + + /** Updates the selected line in place while preserving its stable id. */ + public Line updateSelectedStep(String text) { + Line selected = selectedLine().orElseThrow(() -> + 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; + } + } + + public void stop() { + state = State.STOPPED; + } + + /** Step-only execution always leaves automatic scenario playback paused. */ + public void pauseForIsolatedExecution() { + state = State.PAUSED; + } + + /** + * Advances a successful run to the next executable line and stays in play at end. + * Already-consumed or stale ids are ignored so a leftover Play-loop callback + * cannot abort automatic playback. + */ + public void markCurrentStepExecuted(long stepId) { + int index = currentExecutableIndex(stepId); + if (index < 0) { + return; + } + lastExecutedId = stepId; + executionIndex = findNextExecutableIndex(index + 1); + if (executionIndex < lines.size()) { + playheadId = lines.get(executionIndex).id(); + } else if (state == State.RUNNING) { + state = State.WAITING_FOR_STEP; + } + } + + /** + * Leaves a failed run paused on its failed line. Already-consumed or stale + * ids are ignored. + */ + public void markCurrentStepFailed(long stepId) { + int index = currentExecutableIndex(stepId); + if (index < 0) { + return; + } + executionIndex = index; + 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(); + } + + private int insertionAfterLastExecutable() { + for (int i = lines.size() - 1; i >= 0; i--) { + if (lines.get(i).executable()) return i + 1; + } + 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))); + } + + 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 currentExecutableIndex(long id) { + if (executionIndex >= lines.size() || !lines.get(executionIndex).executable() + || lines.get(executionIndex).id() != id) { + return -1; + } + return executionIndex; + } + + 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 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."); + } + 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/player/ScenarioOrigin.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java new file mode 100644 index 00000000..569dad6d --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java @@ -0,0 +1,33 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +/** Origin of a picker-loaded live buffer. Demo sessions have no save path. */ +public record ScenarioOrigin( + Path file, + String scenarioName, + int startLine, + int endLine +) { + public ScenarioOrigin { + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public static ScenarioOrigin none() { + return new ScenarioOrigin(null, "", 0, 0); + } + + public boolean savable() { + return file != null; + } + + public Optional originFile() { + return Optional.ofNullable(file); + } + + public ScenarioOrigin withEndLine(int newEndLine) { + return new ScenarioOrigin(file, scenarioName, startLine, newEndLine); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java new file mode 100644 index 00000000..adb9df31 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java @@ -0,0 +1,33 @@ +package tools.dscode.workbench.player; + +import tools.dscode.workbench.player.LiveScenarioPlayer.State; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Read-only live-buffer snapshot for MCP/HTTP attach clients. */ +public record WorkbenchPlayerState( + String documentText, + List lines, + State playerState, + Long playheadId, + String playheadText, + Long selectedId, + String sourceFeaturePath, + String scenarioName, + boolean savable +) { + public WorkbenchPlayerState { + Objects.requireNonNull(playerState, "playerState"); + documentText = documentText == null ? "" : documentText; + lines = List.copyOf(lines == null ? List.of() : lines); + sourceFeaturePath = sourceFeaturePath == null ? "" : sourceFeaturePath; + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public Optional originFile() { + return sourceFeaturePath.isBlank() ? Optional.empty() : Optional.of(Path.of(sourceFeaturePath)); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java new file mode 100644 index 00000000..2ba441ea --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java @@ -0,0 +1,29 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Preview of a gated Save into the originating scenario. */ +public record WorkbenchSavePreview( + boolean savable, + Path featurePath, + String scenarioName, + String summary, + List liveScenarioLines +) { + public WorkbenchSavePreview { + scenarioName = scenarioName == null ? "" : scenarioName; + summary = summary == null ? "" : summary; + liveScenarioLines = List.copyOf(liveScenarioLines == null ? List.of() : liveScenarioLines); + } + + public Optional originFile() { + return Optional.ofNullable(featurePath); + } + + public static WorkbenchSavePreview unsavable(String reason) { + return new WorkbenchSavePreview(false, null, "", reason, List.of()); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java new file mode 100644 index 00000000..6480f156 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java @@ -0,0 +1,42 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.Objects; + +/** Outcome of an explicit live-buffer Save. Deny/cancel never writes. */ +public record WorkbenchSaveResult( + boolean written, + String status, + String featurePath, + String scenarioName, + String message +) { + public WorkbenchSaveResult { + Objects.requireNonNull(status, "status"); + featurePath = featurePath == null ? "" : featurePath; + scenarioName = scenarioName == null ? "" : scenarioName; + message = message == null ? "" : message; + } + + public static WorkbenchSaveResult written(Path featurePath, String scenarioName) { + return new WorkbenchSaveResult( + true, + "WRITTEN", + featurePath.toString(), + scenarioName, + "Copied the live scenario into " + featurePath.getFileName() + " / " + scenarioName + "." + ); + } + + public static WorkbenchSaveResult denied() { + return new WorkbenchSaveResult(false, "DENIED", "", "", "Save was denied. The original feature file was not changed."); + } + + public static WorkbenchSaveResult cancelled(String message) { + return new WorkbenchSaveResult(false, "CANCELLED", "", "", message); + } + + public static WorkbenchSaveResult unsavable(String message) { + return new WorkbenchSaveResult(false, "UNSAVABLE", "", "", message); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java index 13fe09d1..d88bdb9c 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java @@ -1,12 +1,15 @@ package tools.dscode.workbench.sync; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; /** Persisted synchronization provenance for one selected consumer project/module. */ +@JsonIgnoreProperties(ignoreUnknown = true) public record WorkbenchManifest( int schemaVersion, String projectRoot, @@ -21,16 +24,69 @@ public record WorkbenchManifest( List dependencyClasspath, String pickleballVersion, String javaVersion, - String javaHome + String javaHome, + String syncMode, + String javaInputFingerprint, + String resourceInputFingerprint, + String buildInputFingerprint, + String dependencyInputFingerprint ) { public static final int CURRENT_SCHEMA = 1; private static final ObjectMapper JSON = new ObjectMapper(); public WorkbenchManifest { - sourceRoots = List.copyOf(sourceRoots); - outputRoots = List.copyOf(outputRoots); - outputMappings = List.copyOf(outputMappings); - dependencyClasspath = List.copyOf(dependencyClasspath); + sourceRoots = List.copyOf(sourceRoots == null ? List.of() : sourceRoots); + outputRoots = List.copyOf(outputRoots == null ? List.of() : outputRoots); + outputMappings = List.copyOf(outputMappings == null ? List.of() : outputMappings); + dependencyClasspath = List.copyOf(dependencyClasspath == null ? List.of() : dependencyClasspath); + syncMode = syncMode == null || syncMode.isBlank() ? WorkbenchSyncMode.FULL.name() : syncMode; + javaInputFingerprint = javaInputFingerprint == null ? "" : javaInputFingerprint; + resourceInputFingerprint = resourceInputFingerprint == null ? "" : resourceInputFingerprint; + buildInputFingerprint = buildInputFingerprint == null ? "" : buildInputFingerprint; + dependencyInputFingerprint = dependencyInputFingerprint == null ? "" : dependencyInputFingerprint; + } + + public boolean hasInputFingerprints() { + return !javaInputFingerprint.isBlank() + && !resourceInputFingerprint.isBlank() + && !buildInputFingerprint.isBlank() + && !dependencyInputFingerprint.isBlank(); + } + + public WorkbenchManifest withSkip(String synchronizedAt, WorkbenchSyncInputs inputs) { + return new WorkbenchManifest( + schemaVersion, + projectRoot, + projectType, + buildTool, + sourceRoots, + outputRoots, + outputMappings, + liveOutput, + synchronizedAt, + fingerprint, + dependencyClasspath, + pickleballVersion, + javaVersion, + javaHome, + WorkbenchSyncMode.SKIPPED.name(), + inputs.javaFingerprint(), + inputs.resourceFingerprint(), + inputs.buildFingerprint(), + inputs.dependencyFingerprint() + ); + } + + static WorkbenchManifest readIfPresent(Path stateRoot) { + Path file = stateRoot.resolve("manifest.json"); + if (!Files.isRegularFile(file)) return null; + try { + WorkbenchManifest manifest = JSON.readValue(file.toFile(), WorkbenchManifest.class); + if (manifest.schemaVersion() != CURRENT_SCHEMA) return null; + return manifest; + } catch (IOException ignored) { + return null; + } } public static WorkbenchManifest read(Path projectRoot) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java new file mode 100644 index 00000000..db0b0b3b --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncInputs.java @@ -0,0 +1,239 @@ +package tools.dscode.workbench.sync; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +/** + * Input-side fingerprints used to decide whether Workbench can skip the wrapper + * or refresh resources without a full test-compile. + * + *

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

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

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

+ */ +public final class WorkbenchSyncPlanner { + private WorkbenchSyncPlanner() { + } + + public static WorkbenchSyncMode decide( + WorkbenchManifest previous, + WorkbenchSyncInputs current, + boolean snapshotReady + ) { + if (previous == null || current == null || !snapshotReady) { + return WorkbenchSyncMode.FULL; + } + if (!previous.hasInputFingerprints()) { + return WorkbenchSyncMode.FULL; + } + if (!current.javaFingerprint().equals(previous.javaInputFingerprint()) + || !current.buildFingerprint().equals(previous.buildInputFingerprint()) + || !current.dependencyFingerprint().equals(previous.dependencyInputFingerprint())) { + return WorkbenchSyncMode.FULL; + } + if (!current.resourceFingerprint().equals(previous.resourceInputFingerprint())) { + return WorkbenchSyncMode.RESOURCES_ONLY; + } + return WorkbenchSyncMode.SKIPPED; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java index 78d89dae..7b100a14 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java @@ -33,19 +33,47 @@ public final class WorkbenchSynchronizer { private static final ObjectMapper JSON = new ObjectMapper(); private static final String GRADLE_METADATA_PREFIX = "PKB_WORKBENCH_METADATA="; + @FunctionalInterface + interface CommandRunner { + String run(WorkbenchProject project, List args, Path log); + } + + private final CommandRunner commandRunner; + + public WorkbenchSynchronizer() { + this(null); + } + + WorkbenchSynchronizer(CommandRunner commandRunner) { + this.commandRunner = commandRunner == null ? this::runProcess : commandRunner; + } + public WorkbenchManifest sync(Path requestedProject) { WorkbenchProject project = WorkbenchProject.locate(requestedProject); Path stateRoot = WorkbenchManifest.workbenchRoot(project.root()); + WorkbenchManifest previous = WorkbenchManifest.readIfPresent(stateRoot); + WorkbenchSyncInputs inputs = WorkbenchSyncInputs.capture(project, previous); + WorkbenchSyncMode mode = WorkbenchSyncPlanner.decide( + previous, inputs, WorkbenchSyncInputs.snapshotReady(stateRoot) + ); + if (mode == WorkbenchSyncMode.RESOURCES_ONLY + && !WorkbenchSyncInputs.compiledOutputsPresent(previous)) { + mode = WorkbenchSyncMode.FULL; + } + if (mode == WorkbenchSyncMode.SKIPPED) { + return skip(previous, inputs, stateRoot); + } + Path logs = stateRoot.resolve("logs"); Path staging = stateRoot.resolve(".sync-" + UUID.randomUUID()); createDirectories(logs, staging); Path log = logs.resolve("sync-" + System.currentTimeMillis() + ".log"); try { - SyncMetadata metadata = project.type() == WorkbenchProject.Type.MAVEN - ? synchronizeMaven(project, staging, log) - : synchronizeGradle(project, staging, log); - return materialize(project, metadata, staging, stateRoot); + SyncMetadata metadata = mode == WorkbenchSyncMode.RESOURCES_ONLY + ? synchronizeResources(project, previous, log) + : synchronizeFull(project, staging, log); + return materialize(project, metadata, staging, stateRoot, mode); } finally { deleteTree(staging); } @@ -56,6 +84,22 @@ static WorkbenchManifest materialize( SyncMetadata metadata, Path staging, Path stateRoot + ) { + return materialize( + project, + metadata, + staging, + stateRoot, + WorkbenchSyncMode.FULL + ); + } + + static WorkbenchManifest materialize( + WorkbenchProject project, + SyncMetadata metadata, + Path staging, + Path stateRoot, + WorkbenchSyncMode mode ) { Path stagedBase = staging.resolve("base"); Path stagedBaseClasses = stagedBase.resolve("classes"); @@ -84,6 +128,9 @@ static WorkbenchManifest materialize( ); List dependencies = distinctExisting(metadata.dependencies()); + WorkbenchSyncInputs stored = WorkbenchSyncInputs.capture( + project, metadata.sourceRoots(), dependencies + ); String fingerprint = fingerprint(stagedBaseClasses, dependencies); Path finalBaseClasses = stateRoot.resolve("base").resolve("classes").toAbsolutePath().normalize(); Path finalLiveClasses = stateRoot.resolve("live").resolve("classes").toAbsolutePath().normalize(); @@ -116,7 +163,12 @@ static WorkbenchManifest materialize( dependencies, implementationVersion(), System.getProperty("java.version", "unknown"), - System.getProperty("java.home", "unknown") + System.getProperty("java.home", "unknown"), + mode == null ? WorkbenchSyncMode.FULL.name() : mode.name(), + stored.javaFingerprint(), + stored.resourceFingerprint(), + stored.buildFingerprint(), + stored.dependencyFingerprint() ); Path stagedManifest = staging.resolve("manifest.json"); @@ -156,10 +208,47 @@ public static List readWorkerClasspath(Path projectRoot) { } } - private SyncMetadata synchronizeMaven(WorkbenchProject project, Path staging, Path log) { + private WorkbenchManifest skip( + WorkbenchManifest previous, + WorkbenchSyncInputs inputs, + Path stateRoot + ) { + WorkbenchManifest updated = previous.withSkip(Instant.now().toString(), inputs); + updated.write(stateRoot.resolve("manifest.json")); + return updated; + } + + private SyncMetadata synchronizeFull(WorkbenchProject project, Path staging, Path log) { + return project.type() == WorkbenchProject.Type.MAVEN + ? synchronizeMavenFull(project, staging, log) + : synchronizeGradleFull(project, staging, log); + } + + private SyncMetadata synchronizeResources( + WorkbenchProject project, + WorkbenchManifest previous, + Path log + ) { + if (project.type() == WorkbenchProject.Type.MAVEN) { + commandRunner.run(project, mavenResourceArgs(project), log); + } else { + commandRunner.run(project, gradleResourceArgs(), log); + } + return metadataFrom(previous); + } + + private SyncMetadata synchronizeMavenFull(WorkbenchProject project, Path staging, Path log) { Path dependencyClasspath = staging.resolve("maven-classpath.txt"); Path effectivePom = staging.resolve("effective-pom.xml"); - List args = List.of( + commandRunner.run(project, mavenFullArgs(project, dependencyClasspath, effectivePom), log); + + MavenMetadata pom = parseEffectivePom(project.root(), effectivePom); + List dependencies = readClasspathValue(dependencyClasspath); + return new SyncMetadata(pom.sourceRoots(), pom.outputs(), dependencies); + } + + static List mavenFullArgs(WorkbenchProject project, Path dependencyClasspath, Path effectivePom) { + return List.of( "-f", project.root().resolve("pom.xml").toString(), "-DskipTests", "test-compile", @@ -170,14 +259,37 @@ private SyncMetadata synchronizeMaven(WorkbenchProject project, Path staging, Pa "org.apache.maven.plugins:maven-help-plugin:3.5.1:effective-pom", "-Doutput=" + effectivePom ); - run(project, args, log); + } + + static List mavenResourceArgs(WorkbenchProject project) { + return List.of( + "-f", project.root().resolve("pom.xml").toString(), + "-DskipTests", + "process-resources", + "process-test-resources" + ); + } - MavenMetadata pom = parseEffectivePom(project.root(), effectivePom); - List dependencies = readClasspathValue(dependencyClasspath); - return new SyncMetadata(pom.sourceRoots(), pom.outputs(), dependencies); + static List gradleResourceArgs() { + return List.of( + "processResources", + "processTestResources", + "--console=plain", + "-q" + ); + } + + private static SyncMetadata metadataFrom(WorkbenchManifest previous) { + List sources = previous.sourceRoots().stream() + .map(value -> Path.of(value).toAbsolutePath().normalize()) + .toList(); + List outputs = previous.outputRoots().stream() + .map(output -> new OutputPath(output.kind(), Path.of(output.path()))) + .toList(); + return new SyncMetadata(sources, outputs, previous.dependencyClasspath()); } - private SyncMetadata synchronizeGradle(WorkbenchProject project, Path staging, Path log) { + private SyncMetadata synchronizeGradleFull(WorkbenchProject project, Path staging, Path log) { Path initScript = staging.resolve("workbench-sync.init.gradle"); writeString(initScript, gradleInitScript()); List args = List.of( @@ -187,7 +299,7 @@ private SyncMetadata synchronizeGradle(WorkbenchProject project, Path staging, P "--console=plain", "-q" ); - String output = run(project, args, log); + String output = commandRunner.run(project, args, log); String metadataLine = output.lines() .filter(line -> line.startsWith(GRADLE_METADATA_PREFIX)) .reduce((first, second) -> second) @@ -234,7 +346,7 @@ static MavenMetadata parseEffectivePom(Path projectRoot, Path effectivePom) { } } - private String run(WorkbenchProject project, List args, Path log) { + private String runProcess(WorkbenchProject project, List args, Path log) { List command = executableCommand(project.launcher(), args); ProcessBuilder builder = new ProcessBuilder(command) .directory(project.buildRoot().toFile()) diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java new file mode 100644 index 00000000..a6f03f07 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java @@ -0,0 +1,118 @@ +package tools.dscode.workbench.terminal; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Presentation buffer for worker/Workbench log text already written to files + * or returned by live execution. It does not invent log lines. + */ +public final class WorkerLogBuffer { + public enum Level { + TRACE, + DEBUG, + INFO, + WARNING, + ERROR + } + + public record Entry(long id, Level level, String raw, String message) { + public Entry { + Objects.requireNonNull(level, "level"); + raw = raw == null ? "" : raw; + message = message == null ? raw : message; + } + } + + private static final Pattern LEVEL_PATTERN = Pattern.compile( + "(?i)(?:\\[\\s*)?(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|SEVERE|FATAL)(?:\\s*\\])?(?:\\s*[:\\-])?\\s*(.*)" + ); + + private final List entries = new ArrayList<>(); + private long nextId = 1; + private Level minimum = Level.INFO; + + public void setMinimum(Level minimum) { + this.minimum = minimum == null ? Level.INFO : minimum; + } + + public Level minimum() { + return minimum; + } + + public void clear() { + entries.clear(); + } + + public List all() { + return List.copyOf(entries); + } + + public List visible() { + List visible = new ArrayList<>(); + for (Entry entry : entries) { + if (entry.level().ordinal() >= minimum.ordinal()) { + visible.add(entry); + } + } + return List.copyOf(visible); + } + + public List appendRaw(String text) { + if (text == null || text.isEmpty()) return List.of(); + List added = new ArrayList<>(); + for (String line : text.split("\\R", -1)) { + if (line.isBlank()) continue; + Entry entry = parse(line); + entries.add(entry); + added.add(entry); + } + return List.copyOf(added); + } + + public Entry parse(String line) { + String raw = line == null ? "" : line; + Matcher matcher = LEVEL_PATTERN.matcher(raw.strip()); + if (matcher.matches()) { + return new Entry(nextId++, levelOf(matcher.group(1)), raw, matcher.group(2)); + } + return new Entry(nextId++, inferUnmarked(raw), raw, raw); + } + + public static Level parseFilter(String raw) { + if (raw == null || raw.isBlank()) return Level.INFO; + String normalized = raw.trim().toUpperCase(Locale.ROOT); + if ("WARN".equals(normalized)) return Level.WARNING; + return Level.valueOf(normalized); + } + + private static Level levelOf(String token) { + String normalized = token.toUpperCase(Locale.ROOT); + return switch (normalized) { + case "TRACE" -> Level.TRACE; + case "DEBUG" -> Level.DEBUG; + case "INFO" -> Level.INFO; + case "WARN", "WARNING" -> Level.WARNING; + case "ERROR", "SEVERE", "FATAL" -> Level.ERROR; + default -> Level.INFO; + }; + } + + /** + * Unmarked worker output is treated as INFO so a scenario-run log remains + * visible at the default filter without fabricating a level the source + * did not print. + */ + private static Level inferUnmarked(String raw) { + String lower = raw.toLowerCase(Locale.ROOT); + if (lower.contains("error") || lower.contains("exception") || lower.contains("failed")) { + return Level.ERROR; + } + if (lower.contains("warn")) return Level.WARNING; + return Level.INFO; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java new file mode 100644 index 00000000..1c3b7c07 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java @@ -0,0 +1,15 @@ +package tools.dscode.workbench.terminal; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Existing Workbench worker stdout/stderr capture files. */ +public record WorkerLogFiles(Path stdout, Path stderr) { + public List existing() { + List files = new ArrayList<>(); + if (stdout != null) files.add(stdout); + if (stderr != null) files.add(stderr); + return List.copyOf(files); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java new file mode 100644 index 00000000..713197d1 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java @@ -0,0 +1,328 @@ +package tools.dscode.workbench.ui; + +import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; +import tools.dscode.workbench.catalog.ScenarioFilter; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.JToggleButton; +import javax.swing.ListSelectionModel; +import javax.swing.border.EmptyBorder; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.GridLayout; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.Consumer; + +/** + * Left-rail scenario picker. Name and tag filters are the primary controls; + * feature-file selection is a collapsed secondary filter. + */ +final class FeaturePickerPanel extends JPanel { + private final DefaultListModel featureModel = new DefaultListModel<>(); + private final DefaultListModel scenarioModel = new DefaultListModel<>(); + private final JList featureList = new JList<>(featureModel); + private final JList scenarioList = new JList<>(scenarioModel); + private final JTextField nameField = new JTextField(); + private final JComboBox nameMatchMode = + new JComboBox<>(ScenarioFilter.NameMatchMode.values()); + private final JTextField includeTags = new JTextField(); + private final JTextField excludeTags = new JTextField(); + private final JToggleButton featureNameMode = new JToggleButton("Feature name"); + private final JToggleButton filePathMode = new JToggleButton("File path"); + private final JToggleButton featureFilterToggle = new JToggleButton("Filter by feature"); + private final JPanel featureFilterPanel = new JPanel(new BorderLayout(0, 4)); + private final JButton saveButton = WorkbenchTheme.flatButton("Save", "Write the live buffer back to the loaded .feature file"); + private final JLabel status = WorkbenchTheme.muted("Showing all project scenarios"); + + private ConsumerFeatureCatalog catalog; + private Consumer onScenario; + private Runnable onSave; + private boolean saveEnabled; + private boolean locked; + + FeaturePickerPanel() { + super(new BorderLayout(8, 8)); + WorkbenchTheme.surface(this); + setBorder(WorkbenchTheme.cardBorder()); + setPreferredSize(new Dimension(300, 640)); + setMinimumSize(new Dimension(240, 320)); + + add(northChrome(), BorderLayout.NORTH); + add(labeled("Scenarios", scenarioList), BorderLayout.CENTER); + add(southChrome(), BorderLayout.SOUTH); + + featureList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + featureList.setCellRenderer((list, value, index, selected, focused) -> { + JLabel label = new JLabel(value == null ? "" : value.browseLabel(catalog == null + ? ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME + : catalog.browseMode())); + label.setOpaque(true); + label.setBorder(new EmptyBorder(4, 6, 4, 6)); + label.setBackground(selected ? WorkbenchTheme.ACCENT_SOFT : WorkbenchTheme.SURFACE); + label.setForeground(WorkbenchTheme.TEXT); + return label; + }); + scenarioList.setCellRenderer((list, value, index, selected, focused) -> { + JLabel label = new JLabel(value == null ? "" : value.displayLabel()); + label.setOpaque(true); + label.setBorder(new EmptyBorder(4, 6, 4, 6)); + label.setBackground(selected ? WorkbenchTheme.PLAYHEAD : WorkbenchTheme.SURFACE); + label.setForeground(WorkbenchTheme.TEXT); + if (value != null) { + String tags = value.effectiveTags().isEmpty() + ? "" + : " @" + String.join(" @", value.effectiveTags()); + label.setToolTipText(value.featureName() + " — " + value.relativePath() + tags); + } + return label; + }); + featureList.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + if (catalog == null || locked) return; + int index = featureList.locationToIndex(event.getPoint()); + if (index < 0) return; + ConsumerFeatureCatalog.FeatureEntry feature = featureModel.get(index); + catalog.toggleFeature(feature.file()); + javax.swing.SwingUtilities.invokeLater(() -> { + syncFeatureSelection(); + refreshScenarios(); + }); + event.consume(); + } + }); + scenarioList.addListSelectionListener(event -> { + if (event.getValueIsAdjusting()) return; + ConsumerFeatureCatalog.ScenarioEntry selected = scenarioList.getSelectedValue(); + if (selected != null && onScenario != null && !locked) onScenario.accept(selected); + }); + nameMatchMode.setSelectedItem(ScenarioFilter.DEFAULT_NAME_MATCH); + nameMatchMode.setToolTipText("Case-insensitive match against the Scenario / Scenario Outline title."); + nameMatchMode.addActionListener(event -> filterChanged()); + listen(nameField); + listen(includeTags); + listen(excludeTags); + nameField.setToolTipText("Filter by scenario name. All four match modes are case-insensitive."); + includeTags.setToolTipText("Tags the scenario must have (AND). With or without @; split on commas or spaces. Empty means no include constraint."); + excludeTags.setToolTipText("Tags the scenario must not have (NOT). Any listed tag drops it. Empty means no exclude constraint."); + saveButton.setEnabled(false); + saveButton.addActionListener(event -> { + if (onSave != null && !locked) onSave.run(); + }); + featureFilterToggle.setFocusable(false); + featureFilterToggle.setToolTipText("Optional. Leave collapsed to apply name/tag filters to every catalog scenario."); + featureFilterToggle.addActionListener(event -> { + featureFilterPanel.setVisible(featureFilterToggle.isSelected()); + revalidate(); + repaint(); + }); + featureNameMode.setSelected(true); + featureNameMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME)); + filePathMode.addActionListener(event -> setMode(ConsumerFeatureCatalog.BrowseMode.FILE_PATH)); + featureFilterPanel.setVisible(false); + } + + void setCatalog(ConsumerFeatureCatalog catalog) { + this.catalog = catalog; + featureModel.clear(); + if (catalog == null) { + refreshScenarios(); + return; + } + applyFiltersFromUi(); + for (ConsumerFeatureCatalog.FeatureEntry feature : catalog.featuresForBrowse()) { + featureModel.addElement(feature); + } + syncFeatureSelection(); + refreshScenarios(); + } + + private void syncFeatureSelection() { + if (catalog == null) return; + featureList.clearSelection(); + for (int i = 0; i < featureModel.size(); i++) { + if (catalog.selected(featureModel.get(i).file())) { + featureList.addSelectionInterval(i, i); + } + } + } + + void onScenarioSelected(Consumer onScenario) { + this.onScenario = onScenario; + } + + void onSave(Runnable onSave) { + this.onSave = onSave; + } + + void setSaveEnabled(boolean enabled) { + saveEnabled = enabled; + saveButton.setEnabled(enabled && !locked); + } + + void setLocked(boolean locked) { + this.locked = locked; + saveButton.setEnabled(saveEnabled && !locked); + featureList.setEnabled(!locked); + scenarioList.setEnabled(!locked); + nameField.setEnabled(!locked); + nameMatchMode.setEnabled(!locked); + includeTags.setEnabled(!locked); + excludeTags.setEnabled(!locked); + featureFilterToggle.setEnabled(!locked); + featureNameMode.setEnabled(!locked); + filePathMode.setEnabled(!locked); + } + + private JPanel northChrome() { + JPanel north = new JPanel(); + north.setOpaque(false); + north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS)); + + JPanel header = new JPanel(new BorderLayout(6, 0)); + header.setOpaque(false); + header.add(WorkbenchTheme.heading("Scenarios"), BorderLayout.WEST); + header.add(saveButton, BorderLayout.EAST); + header.setAlignmentX(LEFT_ALIGNMENT); + header.setMaximumSize(new Dimension(Integer.MAX_VALUE, header.getPreferredSize().height + 8)); + north.add(header); + north.add(Box.createVerticalStrut(8)); + + JPanel nameRow = new JPanel(new BorderLayout(6, 0)); + nameRow.setOpaque(false); + nameRow.add(nameField, BorderLayout.CENTER); + nameMatchMode.setMaximumSize(nameMatchMode.getPreferredSize()); + nameRow.add(nameMatchMode, BorderLayout.EAST); + north.add(labeledField("Scenario name", nameRow)); + north.add(Box.createVerticalStrut(6)); + north.add(labeledField("Must have all tags", includeTags)); + north.add(Box.createVerticalStrut(6)); + north.add(labeledField("Must not have tags", excludeTags)); + north.add(Box.createVerticalStrut(8)); + return north; + } + + private JPanel southChrome() { + JPanel south = new JPanel(); + south.setOpaque(false); + south.setLayout(new BoxLayout(south, BoxLayout.Y_AXIS)); + + featureFilterToggle.setAlignmentX(LEFT_ALIGNMENT); + featureFilterToggle.setMaximumSize(new Dimension(Integer.MAX_VALUE, featureFilterToggle.getPreferredSize().height)); + south.add(featureFilterToggle); + south.add(Box.createVerticalStrut(4)); + + JPanel modes = new JPanel(new GridLayout(1, 2, 4, 0)); + modes.setOpaque(false); + modes.add(featureNameMode); + modes.add(filePathMode); + featureFilterPanel.setOpaque(false); + featureFilterPanel.setAlignmentX(LEFT_ALIGNMENT); + featureFilterPanel.add(modes, BorderLayout.NORTH); + JScrollPane featureScroll = new JScrollPane(featureList); + featureScroll.setBorder(BorderFactory.createLineBorder(WorkbenchTheme.BORDER)); + featureScroll.setPreferredSize(new Dimension(240, 140)); + featureFilterPanel.add(featureScroll, BorderLayout.CENTER); + south.add(featureFilterPanel); + south.add(Box.createVerticalStrut(6)); + status.setAlignmentX(LEFT_ALIGNMENT); + south.add(status); + return south; + } + + private JPanel labeledField(String title, JTextField field) { + JPanel wrap = new JPanel(new BorderLayout(0, 0)); + wrap.setOpaque(false); + wrap.add(field, BorderLayout.CENTER); + return labeledField(title, wrap); + } + + private JPanel labeledField(String title, JPanel field) { + JPanel panel = new JPanel(new BorderLayout(0, 2)); + panel.setOpaque(false); + panel.setAlignmentX(LEFT_ALIGNMENT); + panel.add(WorkbenchTheme.muted(title), BorderLayout.NORTH); + panel.add(field, BorderLayout.CENTER); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + 18)); + return panel; + } + + private JPanel labeled(String title, JList list) { + JPanel panel = new JPanel(new BorderLayout(0, 4)); + panel.setOpaque(false); + panel.add(WorkbenchTheme.muted(title), BorderLayout.NORTH); + JScrollPane scroll = new JScrollPane(list); + scroll.setBorder(BorderFactory.createLineBorder(WorkbenchTheme.BORDER)); + panel.add(scroll, BorderLayout.CENTER); + return panel; + } + + private void setMode(ConsumerFeatureCatalog.BrowseMode mode) { + featureNameMode.setSelected(mode == ConsumerFeatureCatalog.BrowseMode.FEATURE_NAME); + filePathMode.setSelected(mode == ConsumerFeatureCatalog.BrowseMode.FILE_PATH); + if (catalog != null) { + catalog.setBrowseMode(mode); + setCatalog(catalog); + } + } + + private void listen(JTextField field) { + field.getDocument().addDocumentListener(new DocumentListener() { + @Override public void insertUpdate(DocumentEvent event) { filterChanged(); } + @Override public void removeUpdate(DocumentEvent event) { filterChanged(); } + @Override public void changedUpdate(DocumentEvent event) { filterChanged(); } + }); + } + + private void filterChanged() { + if (catalog == null) return; + applyFiltersFromUi(); + refreshScenarios(); + } + + private void applyFiltersFromUi() { + if (catalog == null) return; + ScenarioFilter filter = catalog.filter(); + filter.setNameQuery(nameField.getText()); + Object selected = nameMatchMode.getSelectedItem(); + filter.setNameMatchMode(selected instanceof ScenarioFilter.NameMatchMode mode + ? mode + : ScenarioFilter.DEFAULT_NAME_MATCH); + filter.setIncludeTagsQuery(includeTags.getText()); + filter.setExcludeTagsQuery(excludeTags.getText()); + } + + private void refreshScenarios() { + scenarioModel.clear(); + if (catalog == null) { + status.setText("No synchronized consumer project."); + return; + } + int candidates = catalog.candidateScenarios().size(); + for (ConsumerFeatureCatalog.ScenarioEntry scenario : catalog.visibleScenarios()) { + scenarioModel.addElement(scenario); + } + int visible = scenarioModel.size(); + String featureNote = catalog.selectedFeatureFiles().isEmpty() + ? "all features" + : catalog.selectedFeatureFiles().size() + " feature(s)"; + if (visible == candidates) { + status.setText(visible + " scenario(s) · " + featureNote); + } else { + status.setText(visible + " of " + candidates + " scenario(s) · " + featureNote); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java new file mode 100644 index 00000000..595a8809 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java @@ -0,0 +1,126 @@ +package tools.dscode.workbench.ui; + +import tools.dscode.workbench.terminal.WorkerLogBuffer; +import tools.dscode.workbench.terminal.WorkerLogFiles; + +import javax.swing.JComboBox; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.Timer; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.awt.Font; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Scenario-run log presentation over the existing worker stdout/stderr files. + * It never writes to MCP stdout and never fabricates log lines. + */ +final class TerminalPanel extends JPanel { + private final WorkerLogBuffer buffer = new WorkerLogBuffer(); + private final JTextArea area = new JTextArea(); + private final JComboBox filter = new JComboBox<>(WorkerLogBuffer.Level.values()); + private final Map positions = new LinkedHashMap<>(); + private WorkerLogFiles files; + private final Timer poller = new Timer(750, event -> poll()); + + TerminalPanel() { + super(new BorderLayout(8, 8)); + setBackground(WorkbenchTheme.SURFACE); + setBorder(new EmptyBorder(10, 12, 12, 12)); + + JPanel top = new JPanel(new BorderLayout(8, 0)); + top.setOpaque(false); + top.add(WorkbenchTheme.heading("Worker log"), BorderLayout.WEST); + filter.setSelectedItem(WorkerLogBuffer.Level.INFO); + filter.addActionListener(event -> { + buffer.setMinimum((WorkerLogBuffer.Level) filter.getSelectedItem()); + render(); + }); + top.add(filter, BorderLayout.EAST); + add(top, BorderLayout.NORTH); + + area.setEditable(false); + area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + area.setBackground(WorkbenchTheme.SURFACE); + area.setForeground(WorkbenchTheme.TEXT); + add(new JScrollPane(area), BorderLayout.CENTER); + poller.setRepeats(true); + } + + void start() { + if (!poller.isRunning()) poller.start(); + } + + void stop() { + poller.stop(); + } + + void setFiles(WorkerLogFiles files) { + this.files = files; + positions.clear(); + buffer.clear(); + render(); + poll(); + } + + void appendExecution(String heading, String output, String events) { + StringBuilder text = new StringBuilder(); + if (heading != null && !heading.isBlank()) text.append("[INFO] ").append(heading).append('\n'); + if (output != null && !output.isBlank()) text.append(output).append('\n'); + if (events != null && !events.isBlank()) text.append(events).append('\n'); + buffer.appendRaw(text.toString()); + render(); + } + + void noteGap(String message) { + area.setToolTipText(message); + } + + private void poll() { + if (files == null) return; + for (Path file : files.existing()) { + if (file == null || !Files.isRegularFile(file)) continue; + try { + String next = readSince(file); + if (!next.isEmpty()) buffer.appendRaw(next); + } catch (IOException ignored) { + // A rotating or briefly locked worker log must not crash the UI. + } + } + render(); + } + + private String readSince(Path file) throws IOException { + long previous = positions.getOrDefault(file, 0L); + long size = Files.size(file); + if (size < previous) previous = 0L; + if (size == previous) return ""; + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { + raf.seek(previous); + byte[] bytes = new byte[(int) Math.min(Integer.MAX_VALUE, size - previous)]; + raf.readFully(bytes); + positions.put(file, size); + return new String(bytes, StandardCharsets.UTF_8); + } + } + + private void render() { + StringBuilder text = new StringBuilder(); + for (WorkerLogBuffer.Entry entry : buffer.visible()) { + if (!text.isEmpty()) text.append('\n'); + text.append(entry.raw()); + } + if (!text.toString().equals(area.getText())) { + area.setText(text.toString()); + area.setCaretPosition(area.getDocument().getLength()); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index 6d3eff63..234d4d21 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -1,213 +1,211 @@ 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.catalog.ConsumerFeatureCatalog; +import tools.dscode.workbench.diagnostics.DiagnosticEvidenceNavigator; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.lease.WorkbenchPermissionRequest; +import tools.dscode.workbench.mapping.MappingTreeModel; +import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.mcp.WorkbenchAttachServer; +import tools.dscode.workbench.player.LiveEditorView; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; +import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.ui.web.DiagnosticExplorerHost; +import tools.dscode.workbench.ui.web.GherkinEditorHost; +import tools.dscode.workbench.ui.web.JavaFxSupport; +import tools.dscode.workbench.ui.web.MappingEditorHost; +import tools.dscode.workbench.ui.web.WebViewPanel; +import tools.dscode.workbench.ui.web.WorkbenchWebJson; + import javax.swing.*; +import javax.swing.border.EmptyBorder; +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.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; 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 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 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 eventsArea = outputArea(); - - 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 JTextArea browserOutput = outputArea(); - private final JTabbedPane browserEvidenceTabs = new JTabbedPane(); - private final JLabel screenshotLabel = new JLabel("No screenshot captured.", SwingConstants.CENTER); - private final JTextField serviceSelector = new JTextField("%health-full-url"); - private final JTextArea serviceOutput = outputArea(); - - 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 static final int MAPPING_SAVE_DELAY_MS = 650; + private final WorkbenchUiController controller; + private final WorkbenchAttachServer attach; + private final LiveScenarioPlayer player; + private final LivePlaybackCoordinator playback; + private final ObjectMapper json = new ObjectMapper(); + private final FeaturePickerPanel picker = new FeaturePickerPanel(); + private final TerminalPanel terminal = new TerminalPanel(); + private final GherkinEditorHost gherkinHost = new GherkinEditorHost(); + private final MappingEditorHost mappingHost = new MappingEditorHost(); + private final DiagnosticExplorerHost diagnosticHost = new DiagnosticExplorerHost(); + private WebViewPanel gherkinView; + private WebViewPanel mappingView; + private WebViewPanel diagnosticView; + private JComponent pickerSplit; + private JPanel editorHost; + private LiveEditorView editorView = LiveEditorView.blocksUnavailable(); + private final JToggleButton textViewButton = new JToggleButton("Text"); + private final JToggleButton blocksViewButton = new JToggleButton("Blocks"); + private final JToggleButton pickerToggle = new JToggleButton("Scenarios"); + private List mappingEntries = List.of(); + private MappingTreeModel mappingModel; + private DiagnosticEvidenceNavigator diagnosticNavigator; + + 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"); + 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..."); + private final JLabel playerStatusLabel = new JLabel("Stopped"); private final JLabel activityLabel = new JLabel("Ready"); + private final JPanel agentBanner = new JPanel(new BorderLayout(12, 0)); + private final JLabel agentBannerLabel = new JLabel(); + private final JButton takeControlButton = WorkbenchTheme.accentButton( + "Take control", + "Return live Workbench controls to the human and cancel in-flight agent permission waits" + ); + private final JPanel permissionBar = new JPanel(new BorderLayout(12, 0)); + private final JLabel permissionLabel = new JLabel(); + private final JButton allowButton = WorkbenchTheme.accentButton("Allow", "Allow the agent to copy the live scenario into the original feature file"); + private final JButton denyButton = WorkbenchTheme.flatButton("Deny", "Deny the write; the original feature file is left unchanged"); + private WorkbenchControlLeaseSnapshot lastLease; + private String pendingPermissionId; + + private final JMenuItem syncItem = new JMenuItem("Synchronize"); + private final JMenuItem refreshItem = new JMenuItem("Refresh Status"); + 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 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 JLabel webViewNote = WorkbenchTheme.muted(""); + private WorkbenchUiController.State lastState; + 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 syncingScenarioDocument; private boolean closing; WorkbenchFrame(WorkbenchUiController controller) { + this(controller, null); + } + + WorkbenchFrame(WorkbenchUiController controller, WorkbenchAttachServer attach) { super("Pickleball Workbench"); this.controller = controller; + this.attach = attach; + this.player = controller.player(); + this.playback = controller.playback(); + + mappingSaveTimer.setRepeats(false); + WorkbenchTheme.install(); + getContentPane().setBackground(WorkbenchTheme.BACKGROUND); setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); - setMinimumSize(new Dimension(900, 650)); - setSize(1080, 780); + setMinimumSize(new Dimension(1180, 740)); + setSize(1560, 920); setLocationByPlatform(true); - - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(syncButton); - actions.add(refreshButton); - actions.add(startButton); - actions.add(restartButton); - actions.add(stopButton); - - 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 - )); - 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 - )); - - 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 - )); + setJMenuBar(menuBar()); + configureWebViews(); + + JPanel root = new JPanel(new BorderLayout(10, 10)); + root.setBackground(WorkbenchTheme.BACKGROUND); + root.setBorder(new EmptyBorder(10, 12, 10, 12)); + root.add(topChrome(), BorderLayout.NORTH); + + JSplitPane editorAndRight = new JSplitPane( + JSplitPane.HORIZONTAL_SPLIT, + leftWorkspace(), + rightWorkspace() + ); + WorkbenchTheme.styleSplit(editorAndRight); + editorAndRight.setResizeWeight(0.56); + editorAndRight.setDividerLocation(820); + + JSplitPane withPicker = new JSplitPane( + JSplitPane.HORIZONTAL_SPLIT, + picker, + editorAndRight + ); + WorkbenchTheme.styleSplit(withPicker); + withPicker.setResizeWeight(0.18); + withPicker.setDividerLocation(280); + pickerSplit = withPicker; + root.add(withPicker, BorderLayout.CENTER); + root.add(footer(), BorderLayout.SOUTH); + setContentPane(root); + + configureScenarioEditor(); + configureStepEditor(); + configureMappingEditor(); + configurePicker(); + wirePlayerActions(); + wireSessionActions(); + syncScenarioView(); + updatePlayerView(null); + refreshFeatureCatalog(); + terminal.start(); + + configureAgentChrome(); + controller.addLeaseListener(snapshot -> SwingUtilities.invokeLater(() -> applyLease(snapshot))); + controller.addPlayerListener(() -> SwingUtilities.invokeLater(() -> { + syncScenarioView(); + updatePlayerView(null); + })); + applyLease(controller.controlLease()); addWindowListener(new WindowAdapter() { @Override @@ -219,221 +217,1317 @@ public void windowClosing(WindowEvent event) { runStateAction("Loading project status", controller::refresh); } - private JPanel livePanel() { - JPanel input = new JPanel(new BorderLayout(6, 6)); - input.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + 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; + } - 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 JComponent topChrome() { + JPanel north = new JPanel(); + north.setOpaque(false); + north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS)); + north.add(agentBanner); + north.add(Box.createVerticalStrut(6)); + north.add(permissionBar); + north.add(Box.createVerticalStrut(6)); + north.add(playerBar()); + return north; + } - 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 configureAgentChrome() { + agentBanner.setBackground(new Color(0xFE, 0xF3, 0xC7)); + agentBanner.setBorder(WorkbenchTheme.cardBorder()); + agentBannerLabel.setForeground(WorkbenchTheme.TEXT); + agentBannerLabel.setFont(agentBannerLabel.getFont().deriveFont(Font.BOLD, 13f)); + takeControlButton.addActionListener(event -> { + controller.takeControl(); + applyLease(controller.controlLease()); + updatePlayerView("You took control of Workbench."); + }); + agentBanner.add(agentBannerLabel, BorderLayout.CENTER); + agentBanner.add(takeControlButton, BorderLayout.EAST); + agentBanner.setVisible(false); + + permissionBar.setBackground(new Color(0xDB, 0xEA, 0xFE)); + permissionBar.setBorder(WorkbenchTheme.cardBorder()); + permissionLabel.setForeground(WorkbenchTheme.TEXT); + JPanel permissionButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + permissionButtons.setOpaque(false); + allowButton.addActionListener(event -> answerPermission(true)); + denyButton.addActionListener(event -> answerPermission(false)); + permissionButtons.add(allowButton); + permissionButtons.add(denyButton); + permissionBar.add(permissionLabel, BorderLayout.CENTER); + permissionBar.add(permissionButtons, BorderLayout.EAST); + permissionBar.setVisible(false); + } - input.add(stepLine, BorderLayout.NORTH); - input.add(argument, BorderLayout.CENTER); + private void answerPermission(boolean allow) { + if (pendingPermissionId == null) return; + String id = pendingPermissionId; + pendingPermissionId = null; + controller.answerPermission(id, allow); + applyLease(controller.controlLease()); + updatePlayerView(allow + ? "Allowed the agent Save request." + : "Denied the agent Save request. The original feature file was not changed."); + } - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, input, new JScrollPane(liveOutput)); - split.setResizeWeight(0.45); - split.setBorder(null); + private JPanel playerBar() { + JPanel bar = new JPanel(new BorderLayout(12, 0)); + bar.setBackground(WorkbenchTheme.SURFACE); + bar.setBorder(WorkbenchTheme.cardBorder()); + + JPanel project = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); + project.setOpaque(false); + pickerToggle.setSelected(true); + pickerToggle.setFocusable(false); + pickerToggle.addActionListener(event -> togglePicker()); + project.add(pickerToggle); + project.add(projectLabel); + project.add(readinessLabel); + bar.add(project, BorderLayout.WEST); + + JPanel controls = new JPanel(new FlowLayout(FlowLayout.CENTER, 8, 0)); + controls.setOpaque(false); + controls.add(playButton); + controls.add(pauseButton); + controls.add(playerStopButton); + bar.add(controls, BorderLayout.CENTER); + + JPanel state = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + state.setOpaque(false); + state.add(WorkbenchTheme.muted("Status")); + state.add(playerStatusLabel); + bar.add(state, BorderLayout.EAST); + return bar; + } - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); + 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.setBackground(WorkbenchTheme.SURFACE); + panel.setBorder(WorkbenchTheme.cardBorder()); + + JPanel header = new JPanel(new BorderLayout(8, 0)); + header.setOpaque(false); + header.add(WorkbenchTheme.heading("Live Scenario Editor"), BorderLayout.WEST); + header.add(editorViewToggle(), BorderLayout.EAST); + panel.add(header, BorderLayout.NORTH); + + scenarioEditor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + scenarioEditor.setLineWrap(false); + scenarioEditor.setTabSize(2); + editorHost = new JPanel(new CardLayout()); + editorHost.setOpaque(false); + editorHost.add(new JScrollPane(scenarioEditor), "text"); + if (gherkinView != null) { + editorHost.add(gherkinView, "web"); + } + panel.add(editorHost, BorderLayout.CENTER); + + JPanel legend = new JPanel(new FlowLayout(FlowLayout.LEFT, 18, 2)); + legend.setOpaque(false); + legend.add(WorkbenchTheme.muted("Same live buffer in Text or Blocks")); + legend.add(WorkbenchTheme.muted("Play starts from the first step")); + legend.add(WorkbenchTheme.muted("Click to move the playhead")); + panel.add(legend, BorderLayout.SOUTH); + applyEditorView(); 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); + private JPanel editorViewToggle() { + JPanel toggle = new JPanel(new GridLayout(1, 2, 0, 0)); + toggle.setOpaque(false); + ButtonGroup group = new ButtonGroup(); + textViewButton.setFocusable(false); + blocksViewButton.setFocusable(false); + textViewButton.setToolTipText("View and edit the live scenario as ordinary Gherkin text."); + blocksViewButton.setToolTipText(editorView.canShowBlocks() + ? "View and edit the same live buffer as Gherkin blocks." + : "Block view requires JavaFX WebView, which is not available in this process."); + textViewButton.addActionListener(event -> { + if (humanControlsLocked()) { + applyEditorView(); + return; + } + editorView.showText(); + applyEditorView(); + }); + blocksViewButton.addActionListener(event -> { + if (humanControlsLocked()) { + applyEditorView(); + return; + } + editorView.showBlocks(); + applyEditorView(); + }); + group.add(textViewButton); + group.add(blocksViewButton); + toggle.add(textViewButton); + toggle.add(blocksViewButton); + toggle.setBorder(BorderFactory.createLineBorder(WorkbenchTheme.BORDER)); + return toggle; + } - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); + private void applyEditorView() { + if (editorHost == null) return; + boolean blocks = editorView.showingBlocks() && gherkinView != null; + CardLayout cards = (CardLayout) editorHost.getLayout(); + cards.show(editorHost, blocks ? "web" : "text"); + textViewButton.setSelected(!blocks); + blocksViewButton.setSelected(blocks); + blocksViewButton.setEnabled(editorView.canShowBlocks() && !humanControlsLocked()); + textViewButton.setEnabled(!humanControlsLocked()); + if (blocks) { + pushGherkinView(); + } + } + + 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"); + title.setFont(title.getFont().deriveFont(Font.BOLD)); + header.add(title); + header.add(stepOnlyButton); + header.add(fromHereButton); + 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)); + panel.add(stepText, BorderLayout.CENTER); return panel; } - private JPanel eventsPanel() { + private JComponent rightWorkspace() { + JTabbedPane tabs = new JTabbedPane(); + tabs.addTab("Mapping", mappingPanel()); + tabs.addTab("Terminal", terminal); + tabs.addTab("Diagnostic Log Explorer", diagnosticsPanel()); + tabs.addChangeListener(event -> { + if (tabs.getSelectedIndex() == 2) refreshDiagnostics(); + }); + 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(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - panel.add(eventsRefreshButton, BorderLayout.NORTH); - panel.add(new JScrollPane(eventsArea), BorderLayout.CENTER); + 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); + selector.add(nodeMapSelector, BorderLayout.CENTER); + panel.add(selector, BorderLayout.NORTH); + + 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); + if (mappingView != null) { + JPanel wrap = new JPanel(new BorderLayout()); + wrap.add(mappingView, BorderLayout.CENTER); + wrap.add(mappingStatus, BorderLayout.SOUTH); + return wrap; + } return panel; } - private JPanel 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); + private JPanel diagnosticsPanel() { + JPanel panel = new JPanel(new BorderLayout()); + panel.setBackground(WorkbenchTheme.SURFACE); + if (diagnosticView != null) { + panel.add(diagnosticView, BorderLayout.CENTER); + return panel; + } + panel.setBorder(new EmptyBorder(16, 16, 16, 16)); + JTextArea message = outputArea(); + message.setText(""" + Diagnostic Log Explorer + + JavaFX WebView is unavailable in this process, so the explorer + cannot open the timeline UI. Workbench still reads Pickleball's + retained diagnostic artifacts from reports/diagnostic-runs and + does not invent a second store. + """); + message.setCaretPosition(0); + panel.add(new JScrollPane(message), BorderLayout.CENTER); + return panel; + } - JPanel source = new JPanel(new BorderLayout(6, 6)); - source.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - source.add(fields, BorderLayout.NORTH); - source.add(new JScrollPane(overrideSource), BorderLayout.CENTER); + private JPanel footer() { + JPanel footer = new JPanel(new BorderLayout()); + activityLabel.setBorder(new EmptyBorder(2, 4, 2, 4)); + footer.add(activityLabel, BorderLayout.CENTER); + footer.add(webViewNote, BorderLayout.EAST); + return footer; + } - 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); + private void configureScenarioEditor() { + scenarioEditor.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent event) { + scenarioDocumentChanged(); + } - JTabbedPane outputTabs = new JTabbedPane(); - outputTabs.addTab("Result", new JScrollPane(overrideOutput)); - outputTabs.addTab("Installed", new JScrollPane(overrideList)); + @Override + public void removeUpdate(DocumentEvent event) { + scenarioDocumentChanged(); + } - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, source, outputTabs); - split.setResizeWeight(0.62); - split.setBorder(null); + @Override + public void changedUpdate(DocumentEvent event) { + scenarioDocumentChanged(); + } + }); + scenarioEditor.addCaretListener(event -> seekPlayheadToCaret()); + scenarioEditor.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + seekPlayheadToCaret(); + } + }); + } - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); - return panel; + 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 JPanel evidencePanel() { - JTabbedPane evidenceTabs = new JTabbedPane(); - evidenceTabs.addTab("Browser", browserEvidencePanel()); - evidenceTabs.addTab("Service Call", serviceEvidencePanel()); + private void configureWebViews() { + if (!JavaFxSupport.available()) { + editorView = LiveEditorView.blocksUnavailable(); + webViewNote.setText("JavaFX WebView unavailable: " + JavaFxSupport.failure() + + ". Using the text fallback. OpenJFX is Workbench-only. Block view is unavailable."); + return; + } + try { + gherkinHost.onDocument(this::applyEditorLines); + gherkinHost.onSeek(id -> { + if (humanControlsLocked()) return; + player.clickLine(id); + updateFromHereAvailability(); + refreshPlayheadHighlight(); + }); + gherkinHost.onAddStep(this::insertStep); + gherkinHost.onReady(this::pushGherkinView); + gherkinView = new WebViewPanel( + "/tools/dscode/workbench/ui/web/gherkin-editor.html", + "gherkinHost", + gherkinHost + ); + + mappingHost.onSelect(reference -> { + for (WorkbenchUiController.MappingCatalogEntry entry : mappingEntries) { + if (entry.reference().equals(reference)) { + nodeMapSelector.setSelectedItem(entry); + loadMapping(entry); + return; + } + } + }); + mappingHost.onEdit(this::applyMappingPropertyEdit); + mappingHost.onReady(this::pushMappingView); + mappingView = new WebViewPanel( + "/tools/dscode/workbench/ui/web/mapping-editor.html", + "mappingHost", + mappingHost + ); + + diagnosticHost.onSelectRun(this::showDiagnosticRun); + diagnosticHost.onReady(this::refreshDiagnostics); + diagnosticView = new WebViewPanel( + "/tools/dscode/workbench/ui/web/diagnostic-explorer.html", + "diagnosticHost", + diagnosticHost + ); + editorView = LiveEditorView.blocksAvailable(); + } catch (RuntimeException failure) { + gherkinView = null; + mappingView = null; + diagnosticView = null; + editorView = LiveEditorView.blocksUnavailable(); + webViewNote.setText("JavaFX WebView failed to start: " + failure.getMessage() + + ". Using the text fallback. Block view is unavailable."); + } + } - JPanel panel = new JPanel(new BorderLayout()); - panel.add(evidenceTabs, BorderLayout.CENTER); - return panel; + private void configurePicker() { + picker.onScenarioSelected(scenario -> { + controller.loadPickerScenario( + scenario.lines(), + scenario.file(), + scenario.name(), + scenario.startLine(), + scenario.endLine() + ); + picker.setSaveEnabled(true); + syncScenarioView(); + updatePlayerView("Loaded " + scenario.displayLabel() + " into the live session buffer."); + }); + picker.onSave(this::saveLoadedFeature); } - private JPanel browserEvidencePanel() { - JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT)); - actions.add(browserPageButton); - actions.add(browserScreenshotButton); + private void refreshFeatureCatalog() { + WorkbenchManifest manifest = null; + try { + manifest = WorkbenchManifest.read(controller.projectRoot()); + } catch (RuntimeException ignored) { + // The picker can still scan conventional project feature folders. + } + picker.setCatalog(ConsumerFeatureCatalog.scan(controller.projectRoot(), manifest)); + diagnosticNavigator = new DiagnosticEvidenceNavigator(controller.projectRoot()); + } - screenshotLabel.setVerticalAlignment(SwingConstants.TOP); - JScrollPane screenshotScroll = new JScrollPane(screenshotLabel); - screenshotScroll.getVerticalScrollBar().setUnitIncrement(16); - screenshotScroll.getHorizontalScrollBar().setUnitIncrement(16); + private void togglePicker() { + if (!(pickerSplit instanceof JSplitPane split)) return; + if (pickerToggle.isSelected()) { + split.setDividerLocation(280); + picker.setVisible(true); + } else { + split.setDividerLocation(0); + picker.setVisible(false); + } + split.revalidate(); + } - browserEvidenceTabs.addTab("Page Evidence", new JScrollPane(browserOutput)); - browserEvidenceTabs.addTab("Screenshot", screenshotScroll); + private void applyEditorLines(List lines) { + if (syncingScenarioDocument || humanControlsLocked()) return; + player.replaceDocument(lines); + updateFromHereAvailability(); + if (player.state() == LiveScenarioPlayer.State.RUNNING) { + if (lastState == null || !lastState.liveReady()) { + prepareLiveSession(this::schedulePlaybackStep); + } else { + schedulePlaybackStep(); + } + } + } - JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - panel.add(actions, BorderLayout.NORTH); - panel.add(browserEvidenceTabs, BorderLayout.CENTER); - return panel; + private void pushGherkinView() { + if (gherkinView == null) return; + gherkinView.evalJsonCall("window.setEditorState", WorkbenchWebJson.editorState( + player, + executingStepId, + humanControlsLocked() + )); } - 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 pushMappingView() { + if (mappingView == null) return; + WorkbenchUiController.MappingCatalogEntry selected = + (WorkbenchUiController.MappingCatalogEntry) nodeMapSelector.getSelectedItem(); + mappingView.evalJsonCall( + "window.setMappingState", + WorkbenchWebJson.mappingState( + mappingEntries.stream() + .map(entry -> new WorkbenchWebJson.MapChoice( + entry.reference(), entry.label(), entry.restorable())) + .toList(), + selected == null ? null : new WorkbenchWebJson.MapChoice( + selected.reference(), selected.label(), selected.restorable()), + mappingModel, + mappingStatus.getText(), + humanControlsLocked() + ) + ); + } - JPanel panel = new JPanel(new BorderLayout(6, 6)); - panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - panel.add(controls, BorderLayout.NORTH); - panel.add(new JScrollPane(serviceOutput), BorderLayout.CENTER); - return panel; + private void applyMappingPropertyEdit(MappingEditorHost.PropertyEdit edit) { + if (humanControlsLocked()) return; + if (lastState == null || !lastState.liveReady() || loadedMapping == null) return; + try { + if (edit.oldKey() == null || edit.oldKey().isBlank() || edit.oldKey().equals(edit.key())) { + runTask( + () -> controller.mappingPutTyped(edit.mapReference(), edit.key(), edit.type(), edit.text()), + result -> { + terminal.appendExecution("[Mapping] " + edit.key(), result.output(), result.events()); + mappingStatus.setText("Saved " + edit.key() + " through mappingPut."); + if (mappingModel != null) { + mappingModel = mappingModel.upsert( + edit.key(), + MappingValueCodec.parseType(edit.type()), + edit.text() + ); + } + pushMappingView(); + }, + failure -> showFailure("Mapping property edit failed", failure) + ); + return; + } + MappingTreeModel updated = (mappingModel == null + ? new MappingTreeModel(edit.mapReference(), loadedMapping.mapType(), true, Map.of()) + : mappingModel) + .rename(edit.oldKey(), edit.key()) + .upsert(edit.key(), MappingValueCodec.parseType(edit.type()), edit.text()); + runTask( + () -> controller.restoreMapping(loadedMapping, updated.values()), + output -> { + mappingModel = updated; + terminal.appendExecution("[Mapping] " + loadedMapping.mapType(), output, ""); + mappingStatus.setText("Renamed property saved through mappingRestore."); + pushMappingView(); + }, + failure -> showFailure("Mapping restore failed", failure) + ); + } catch (RuntimeException failure) { + mappingStatus.setText(failure.getMessage()); + } } - private 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); + private void saveLoadedFeature() { + if (humanControlsLocked()) return; + WorkbenchSavePreview preview = controller.savePreview(); + if (!preview.savable()) { + showFailure("Could not save", new IllegalStateException(preview.summary())); + return; + } + int choice = JOptionPane.showConfirmDialog( + this, + preview.summary() + "\n\nWorkbench will not write the original feature file unless you confirm.", + "Save live scenario", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.QUESTION_MESSAGE + ); + if (choice != JOptionPane.OK_OPTION) { + updatePlayerView("Save cancelled. The original feature file was not changed."); + return; + } + try { + WorkbenchSaveResult result = controller.commitSave(); + if (result.written()) { + updatePlayerView(result.message()); + } else { + showFailure("Could not save", new IllegalStateException(result.message())); + } + } catch (RuntimeException failure) { + showFailure("Could not save feature file", failure); + } + } - JPanel controls = new JPanel(new BorderLayout()); - controls.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); - controls.add(fields, BorderLayout.CENTER); + private void refreshDiagnostics() { + if (diagnosticView == null) return; + if (diagnosticNavigator == null) { + diagnosticNavigator = new DiagnosticEvidenceNavigator(controller.projectRoot()); + } + List runs = diagnosticNavigator.catalogRuns(); + if (runs.isEmpty()) { + diagnosticView.evalJsonCall("window.setDiagnosticState", WorkbenchWebJson.write(Map.of( + "runs", List.of(), + "frames", List.of(), + "layers", List.of(), + "gap", diagnosticNavigator.available() + ? "The catalog has no retained runs." + : "No reports/diagnostic-runs/run-catalog.json in this consumer project." + ))); + return; + } + showDiagnosticRun(runs.getFirst().runId()); + } - 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); + private void showDiagnosticRun(String runId) { + if (diagnosticView == null || diagnosticNavigator == null) return; + DiagnosticEvidenceNavigator.CatalogRun selected = diagnosticNavigator.catalogRuns().stream() + .filter(run -> run.runId().equals(runId)) + .findFirst() + .orElse(null); + if (selected == null) return; + DiagnosticEvidenceNavigator.Timeline timeline = diagnosticNavigator.timeline(selected.runRoot()); + List> frames = new ArrayList<>(); + for (DiagnosticEvidenceNavigator.ScreenshotFrame frame : timeline.frames()) { + Map item = new LinkedHashMap<>(); + item.put("stepText", frame.stepText()); + item.put("scenarioId", frame.scenarioId()); + try { + byte[] bytes = Files.readAllBytes(frame.file()); + item.put("dataUri", "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes)); + } catch (Exception ignored) { + continue; + } + frames.add(item); + } + String scenarioId = timeline.frames().isEmpty() ? "" : timeline.frames().getFirst().scenarioId(); + List> layers = new ArrayList<>(); + for (var layer : diagnosticNavigator.layers(selected.runRoot(), scenarioId)) { + layers.add(Map.of( + "layer", layer.layer().name(), + "present", layer.present(), + "excerpt", layer.excerpt() == null ? "" : layer.excerpt() + )); + } + List> runs = new ArrayList<>(); + for (var run : diagnosticNavigator.catalogRuns()) { + runs.add(Map.of( + "runId", run.runId(), + "label", run.runId(), + "selected", run.runId().equals(runId) + )); + } + diagnosticView.evalJsonCall("window.setDiagnosticState", WorkbenchWebJson.write(Map.of( + "runs", runs, + "frames", frames, + "layers", layers, + "index", 0, + "gap", frames.isEmpty() ? "This retained run has no PNG frames." : "" + ))); + } - JTabbedPane outputTabs = new JTabbedPane(); - outputTabs.addTab("Result", new JScrollPane(breakpointOutput)); - outputTabs.addTab("Installed", new JScrollPane(breakpointList)); + private void configureMappingEditor() { + nodeMapSelector.addActionListener(event -> { + if (refreshingCatalog) return; + WorkbenchUiController.MappingCatalogEntry selected = + (WorkbenchUiController.MappingCatalogEntry) nodeMapSelector.getSelectedItem(); + if (selected != null) loadMapping(selected); + }); - JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, controls, outputTabs); - split.setResizeWeight(0.5); - split.setBorder(null); + mappingEditor.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent event) { + mappingChanged(); + } - JPanel panel = new JPanel(new BorderLayout()); - panel.add(split, BorderLayout.CENTER); - return panel; + @Override + public void removeUpdate(DocumentEvent event) { + mappingChanged(); + } + + @Override + public void changedUpdate(DocumentEvent event) { + mappingChanged(); + } + }); } - private void runStateAction(String label, Supplier action) { - runBackground(label, action, this::applyState); + private void wirePlayerActions() { + playButton.addActionListener(event -> runScenarioFromBeginning()); + pauseButton.addActionListener(event -> { + if (humanControlsLocked()) return; + player.pause(); + updatePlayerView(playbackBusy + ? "Pause requested; the current step will finish first." + : "Scenario playback paused."); + }); + playerStopButton.addActionListener(event -> { + if (humanControlsLocked()) return; + player.stop(); + pendingFreshRun = false; + pendingFreshRunStepId = null; + pendingIsolatedStep = null; + updatePlayerView(playbackBusy + ? "Scenario playback stopped; the current step will finish but no next step will start." + : "Scenario playback stopped."); + }); + stepOnlyButton.addActionListener(event -> executeStepOnly()); + fromHereButton.addActionListener(event -> runScenarioFromSelectedStep()); } - private void runLiveAction( - String label, - Supplier action, - Consumer output - ) { - runBackground(label, action, result -> { - output.accept(result.output()); - appendEvents(result.events()); + 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); }); } - private void runManagementAction( - String label, - Supplier action, - JTextArea output, - JTextArea listing - ) { - runBackground(label, action, result -> { - output.setText(result.output()); - listing.setText(result.listing()); + private void runScenarioFromBeginning() { + requestFreshRun(null); + } + + private void runScenarioFromSelectedStep() { + 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.")); + return; + } + requestFreshRun(selected.id()); + } + + private void requestFreshRun(Long startStepId) { + if (humanControlsLocked()) return; + 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.clickLine(startStepId); + player.startFromSelectedStep(); + showLine(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()); + + // executeStep already advanced or paused the playhead while RUNNING. + // Do not remake that mark here; a leftover mark of the captured id + // used to abort automatic playback after the first successful step. + if (!result.successful()) { + player.clickLine(step.id()); + showLine(step.id()); + } + 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.clickLine(step.id()); + syncScenarioView(); + showLine(step.id()); + updatePlayerView("Step execution failed. Scenario playback paused."); + showFailure("Could not execute live step", failure); + if (!runPendingFreshRun()) runPendingIsolatedStep(); + } + ); + } + + private void insertStep() { + if (humanControlsLocked()) return; + try { + LiveScenarioPlayer.Line inserted = player.insertStep(stepText.getText()); + stepText.setText(""); + player.clickLine(inserted.id()); + syncScenarioView(); + 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); + } else { + schedulePlaybackStep(); + } + } + } catch (RuntimeException failure) { + showFailure("Could not insert step", failure); + } + } + + private void updateSelectedStep() { + if (humanControlsLocked()) return; + try { + LiveScenarioPlayer.Line updated = player.updateSelectedStep(stepText.getText()); + syncScenarioView(); + showLine(updated.id()); + updatePlayerView("Updated selected line in place."); + } catch (RuntimeException failure) { + showFailure("Could not update selected step", failure); + } + } + + private void executeStepOnly() { + if (humanControlsLocked()) return; + String text = stepText.getText(); + if (text == null || text.isBlank()) { + showFailure("Could not execute step", + new IllegalArgumentException("Step Editor text must not be blank.")); + return; + } + + player.pauseForIsolatedExecution(); + updatePlayerView("Scenario playback paused for Step Only execution."); + + if (playbackBusy || playbackPreparing) { + pendingIsolatedStep = text; + activityLabel.setText("Step Only execution queued after the current operation."); + return; + } + executeStepOnlyNow(text); + } + + 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); + } + } + + private boolean runPendingFreshRun() { + if (!pendingFreshRun || playbackBusy || playbackPreparing) return false; + Long startStepId = pendingFreshRunStepId; + pendingFreshRun = false; + pendingFreshRunStepId = null; + startFreshRunNow(startStepId); + return true; + } + + private boolean runPendingIsolatedStep() { + if (pendingIsolatedStep == null || playbackBusy || playbackPreparing || pendingFreshRun) return false; + String text = pendingIsolatedStep; + pendingIsolatedStep = null; + executeStepOnlyNow(text); + return true; + } + + private void mappingChanged() { + if (humanControlsLocked()) return; + if (loadingMapping || loadedMapping == null || !loadedMapping.restorable()) return; + if (player.state() == LiveScenarioPlayer.State.RUNNING + || player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP) { + player.pause(); + updatePlayerView("Player paused for live Mapping edit."); + } + mappingEditGeneration++; + mappingStatus.setText("Editing " + loadedMapping.mapType() + "..."); + mappingSaveTimer.restart(); + } + + private void refreshMappingCatalog() { + if (refreshingCatalog || lastState == null || !lastState.liveReady()) { + if (lastState == null || !lastState.liveReady()) { + nodeMapSelector.setEnabled(false); + mappingEditor.setEnabled(false); + } + return; + } + + 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; + } + mappingEntries = List.copyOf(entries); + 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 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; + mappingModel = new MappingTreeModel( + snapshot.mapReference(), + snapshot.mapType(), + snapshot.restorable(), + snapshot.values() + ); + try { + String formatted = json.writerWithDefaultPrettyPrinter() + .writeValueAsString(snapshot.values()); + setMappingEditor(formatted, snapshot.restorable()); + mappingStatus.setText( + snapshot.restorable() + ? "Live NodeMap from the worker ParsingMap. Edits use mappingPut / mappingRestore." + : "Inspection only: this NodeMap implementation is not safely restorable." + ); + pushMappingView(); + } catch (Exception failure) { + showFailure("Could not render NodeMap JSON", failure); + } + }, + failure -> { + loadedMapping = null; + setMappingEditor("", false); + mappingStatus.setText("Could not load NodeMap: " + failure.getMessage()); + } + ); + } + + 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; + } + + Map values; + try { + values = json.readValue( + mappingEditor.getText(), + new TypeReference>() { } + ); + } catch (Exception invalidJson) { + mappingStatus.setText("Invalid JSON — edit has not been applied."); + return; + } + + 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 void setMappingEditor(String text, boolean editable) { + loadingMapping = true; + try { + mappingEditor.setText(text); + mappingEditor.setCaretPosition(0); + mappingEditor.setEnabled(true); + mappingEditor.setEditable(editable); + } finally { + loadingMapping = false; + } + } + + private void scenarioDocumentChanged() { + if (syncingScenarioDocument || humanControlsLocked()) 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 || humanControlsLocked()) 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() { + 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; + } + } + refreshPlayheadHighlight(); + updateFromHereAvailability(); + pushGherkinView(); + } + + private void updateFromHereAvailability() { + fromHereButton.setEnabled( + !humanControlsLocked() + && player.selectedLine() + .or(player::playheadLine) + .map(LiveScenarioPlayer.Line::executable) + .orElse(false) + ); + } + + 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"; + 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 runTextAction(String label, Supplier action, Consumer output) { - runBackground(label, action, output); + private void appendTerminal(String heading, String output, String events) { + terminal.appendExecution(heading, output, events); + controller.workerLogFiles().ifPresent(terminal::setFiles); } - private void runBackground(String label, Supplier action, Consumer success) { - if (closing) return; - setControlsEnabled(false); + private void runStateAction( + String label, + Supplier action + ) { activityLabel.setText(label + "..."); + runTask( + action, + state -> { + applyState(state); + activityLabel.setText(label + " complete."); + if (state.liveReady()) { + refreshMappingCatalog(); + controller.workerLogFiles().ifPresent(terminal::setFiles); + } + refreshFeatureCatalog(); + }, + failure -> showFailure(label + " failed", failure) + ); + } + + 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."); + } + if (humanControlsLocked()) { + applyLease(lastLease); + } + } + private boolean humanControlsLocked() { + return lastLease != null && lastLease.agentHolds(); + } + + private void applyLease(WorkbenchControlLeaseSnapshot snapshot) { + lastLease = snapshot; + boolean locked = snapshot != null && snapshot.agentHolds(); + agentBanner.setVisible(locked); + if (locked) { + agentBannerLabel.setText(snapshot.bannerText()); + } + WorkbenchPermissionRequest pending = snapshot == null ? null : snapshot.pendingPermission(); + permissionBar.setVisible(pending != null); + if (pending != null) { + pendingPermissionId = pending.id(); + permissionLabel.setText(pending.summary()); + } else { + pendingPermissionId = null; + } + + picker.setLocked(locked); + pickerToggle.setEnabled(!locked); + scenarioEditor.setEditable(!locked); + stepText.setEditable(!locked); + playButton.setEnabled(!locked); + pauseButton.setEnabled(!locked); + playerStopButton.setEnabled(!locked); + stepOnlyButton.setEnabled(!locked); + textViewButton.setEnabled(!locked); + blocksViewButton.setEnabled(editorView.canShowBlocks() && !locked); + takeControlButton.setEnabled(locked); + if (locked) { + fromHereButton.setEnabled(false); + syncItem.setEnabled(false); + startItem.setEnabled(false); + restartItem.setEnabled(false); + stopItem.setEnabled(false); + nodeMapSelector.setEnabled(false); + mappingEditor.setEnabled(false); + } else if (lastState != null) { + syncItem.setEnabled(!lastState.workerRunning()); + startItem.setEnabled(lastState.synchronizedProject() && !lastState.workerRunning()); + restartItem.setEnabled(lastState.workerRunning()); + stopItem.setEnabled(lastState.workerRunning()); + if (lastState.liveReady()) { + nodeMapSelector.setEnabled(nodeMapSelector.getItemCount() > 0); + } + } + updateFromHereAvailability(); + pushGherkinView(); + pushMappingView(); + } + + private void runTask( + Supplier action, + Consumer success, + Consumer failure + ) { new SwingWorker() { @Override protected T doInBackground() { @@ -444,156 +1538,285 @@ protected T doInBackground() { protected void done() { try { success.accept(get()); - activityLabel.setText(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; - 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 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); + /** + * 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); + + 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 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 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 showFailure(String label, Throwable failure) { - String message = failure == null ? null : failure.getMessage(); - activityLabel.setText(label + " failed: " + ((message == null || message.isBlank()) - ? String.valueOf(failure) - : message)); + 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(); + + 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 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); - 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) { - syncButton.setEnabled(enabled); - refreshButton.setEnabled(enabled); - startButton.setEnabled(enabled); - restartButton.setEnabled(enabled); - stopButton.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); - breakpointClearButton.setEnabled(enabled); + 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 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(); + terminal.stop(); + activityLabel.setText("Closing Workbench..."); + runTask( + () -> { + if (attach != null) attach.close(); + controller.close(); + return Boolean.TRUE; + }, + ignored -> { + dispose(); + }, + failure -> { dispose(); } - } - }.execute(); + ); } 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)); + area.setLineWrap(false); return area; } + private static JButton playerButton(String text, String tooltip) { + return WorkbenchTheme.flatButton(text, tooltip); + } + + 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; @@ -602,12 +1825,13 @@ 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; } } """; } + } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java new file mode 100644 index 00000000..cc55289e --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java @@ -0,0 +1,134 @@ +package tools.dscode.workbench.ui; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSplitPane; +import javax.swing.UIManager; +import javax.swing.border.Border; +import javax.swing.border.EmptyBorder; +import javax.swing.plaf.ColorUIResource; +import javax.swing.plaf.FontUIResource; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Insets; + +/** Flat, readable Swing chrome for the Workbench player. */ +final class WorkbenchTheme { + static final Color BACKGROUND = new Color(0xF4, 0xF6, 0xF8); + static final Color SURFACE = Color.WHITE; + static final Color SURFACE_ALT = new Color(0xEE, 0xF2, 0xF7); + static final Color BORDER = new Color(0xD7, 0xDE, 0xE7); + static final Color TEXT = new Color(0x1F, 0x29, 0x37); + static final Color MUTED = new Color(0x6B, 0x72, 0x80); + static final Color ACCENT = new Color(0x25, 0x63, 0xEB); + static final Color ACCENT_SOFT = new Color(0xDB, 0xEA, 0xFE); + static final Color PLAYHEAD = new Color(0xFE, 0xF3, 0xC7); + static final Color DANGER = new Color(0xB9, 0x1C, 0x1C); + + private WorkbenchTheme() { + } + + static void install() { + UIManager.put("Panel.background", new ColorUIResource(BACKGROUND)); + UIManager.put("OptionPane.background", new ColorUIResource(BACKGROUND)); + UIManager.put("Label.foreground", new ColorUIResource(TEXT)); + UIManager.put("Button.background", new ColorUIResource(SURFACE)); + UIManager.put("Button.foreground", new ColorUIResource(TEXT)); + UIManager.put("Button.focus", new ColorUIResource(ACCENT_SOFT)); + UIManager.put("ToggleButton.background", new ColorUIResource(SURFACE)); + UIManager.put("TextField.background", new ColorUIResource(SURFACE)); + UIManager.put("TextArea.background", new ColorUIResource(SURFACE)); + UIManager.put("ComboBox.background", new ColorUIResource(SURFACE)); + UIManager.put("List.background", new ColorUIResource(SURFACE)); + UIManager.put("TabbedPane.background", new ColorUIResource(BACKGROUND)); + UIManager.put("TabbedPane.contentAreaColor", new ColorUIResource(SURFACE)); + UIManager.put("SplitPane.background", new ColorUIResource(BACKGROUND)); + UIManager.put("MenuBar.background", new ColorUIResource(SURFACE)); + Font base = new Font("SansSerif", Font.PLAIN, 13); + UIManager.put("Label.font", new FontUIResource(base)); + UIManager.put("Button.font", new FontUIResource(base)); + UIManager.put("ToggleButton.font", new FontUIResource(base)); + UIManager.put("TextField.font", new FontUIResource(base)); + UIManager.put("ComboBox.font", new FontUIResource(base)); + UIManager.put("TabbedPane.font", new FontUIResource(base.deriveFont(Font.BOLD, 12f))); + } + + static Border cardBorder() { + return BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + new EmptyBorder(10, 12, 10, 12) + ); + } + + static JPanel card(String title) { + JPanel panel = new JPanel(); + panel.setBackground(SURFACE); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + new EmptyBorder(10, 12, 12, 12) + )); + if (title != null) { + panel.setName(title); + } + return panel; + } + + static JLabel muted(String text) { + JLabel label = new JLabel(text); + label.setForeground(MUTED); + return label; + } + + static JLabel heading(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(Font.BOLD, 13f)); + label.setForeground(TEXT); + return label; + } + + static JButton flatButton(String text, String tooltip) { + JButton button = new JButton(text); + button.setToolTipText(tooltip); + button.setFocusable(false); + button.setBackground(SURFACE); + button.setForeground(TEXT); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + new EmptyBorder(6, 12, 6, 12) + )); + button.setMargin(new Insets(2, 8, 2, 8)); + return button; + } + + static JButton accentButton(String text, String tooltip) { + JButton button = flatButton(text, tooltip); + button.setBackground(ACCENT); + button.setForeground(Color.WHITE); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(ACCENT.darker()), + new EmptyBorder(6, 14, 6, 14) + )); + return button; + } + + static void styleSplit(JSplitPane split) { + split.setBorder(BorderFactory.createEmptyBorder()); + split.setContinuousLayout(true); + split.setDividerSize(8); + split.setBackground(BACKGROUND); + } + + static void surface(JComponent component) { + component.setBackground(SURFACE); + component.setForeground(TEXT); + component.setOpaque(true); + } + + static Dimension compact(int width, int height) { + return new Dimension(width, height); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java index 44cef261..c4689e05 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java @@ -1,6 +1,7 @@ package tools.dscode.workbench.ui; import tools.dscode.workbench.WorkbenchController; +import tools.dscode.workbench.mcp.WorkbenchAttachServer; import javax.swing.*; import java.lang.reflect.InvocationTargetException; @@ -13,11 +14,12 @@ private WorkbenchUi() { public static void launch(Path projectRoot) { Runnable launch = () -> { - WorkbenchUiController controller = new WorkbenchUiController( - projectRoot, - new WorkbenchController(projectRoot) - ); - new WorkbenchFrame(controller).setVisible(true); + WorkbenchTheme.install(); + WorkbenchController services = new WorkbenchController(projectRoot); + services.attachUi(); + WorkbenchAttachServer attach = WorkbenchAttachServer.start(services, projectRoot); + WorkbenchUiController controller = new WorkbenchUiController(projectRoot, services); + new WorkbenchFrame(controller, attach).setVisible(true); }; if (SwingUtilities.isEventDispatchThread()) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java index 04a49871..a48c30ff 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java @@ -1,29 +1,43 @@ 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.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; +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.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; 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; +import java.util.Optional; /** Thin presentation adapter over the shared Workbench service surface. */ final class WorkbenchUiController implements AutoCloseable { @@ -78,11 +92,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"), @@ -92,14 +235,76 @@ LiveActionResult mappingGet(String mapReference, String key) { } LiveActionResult mappingPut(String mapReference, String key, String value) { + return mappingPutValue(mapReference, key, value == null ? "" : value); + } + + LiveActionResult mappingPutTyped(String mapReference, String key, String type, String text) { + return mappingPutValue(mapReference, key, MappingValueCodec.decode(type, text)); + } + + LiveActionResult mappingPutValue(String mapReference, String key, Object value) { ControlBridgeValueResult result = services.mappingPut( required(mapReference, "Mapping reference"), required(key, "Mapping key"), - value == null ? "" : value + value ); return new LiveActionResult(renderValueResult(result), refreshEvents()); } + Path projectRoot() { + return projectRoot; + } + + LiveScenarioPlayer player() { + return services.player(); + } + + LivePlaybackCoordinator playback() { + return services.playback(); + } + + WorkbenchControlLeaseSnapshot controlLease() { + return services.controlLeaseSnapshot(); + } + + WorkbenchControlLeaseSnapshot takeControl() { + return services.takeControl(); + } + + void answerPermission(String requestId, boolean allow) { + services.answerPermission(requestId, allow); + } + + WorkbenchSavePreview savePreview() { + return services.savePreview(); + } + + WorkbenchSaveResult commitSave() { + return services.commitSave(); + } + + void loadPickerScenario( + java.util.List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + services.loadPickerScenario(lines, originFile, scenarioName, startLine, endLine); + } + + void addLeaseListener(java.util.function.Consumer listener) { + services.addLeaseListener(listener); + } + + void addPlayerListener(Runnable listener) { + services.addPlayerListener(listener); + } + + Optional workerLogFiles() { + return services.workerLogFiles(); + } + LiveActionResult mappingResolve(String input) { ControlBridgeValueResult result = services.mappingResolve(required(input, "Mapping input")); return new LiveActionResult(renderValueResult(result), refreshEvents()); @@ -197,6 +402,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) { @@ -281,7 +499,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 +512,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)"); @@ -388,6 +610,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/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java new file mode 100644 index 00000000..b8f7de4d --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.ui.web; + +import java.util.function.Consumer; + +/** JavaScript bridge for retained-run navigation. It does not create diagnostic data. */ +public final class DiagnosticExplorerHost { + private Consumer onSelectRun; + private Consumer onFocusLayer; + private Runnable onReady; + + public void onSelectRun(Consumer onSelectRun) { + this.onSelectRun = onSelectRun; + } + + public void onFocusLayer(Consumer onFocusLayer) { + this.onFocusLayer = onFocusLayer; + } + + public void onReady(Runnable onReady) { + this.onReady = onReady; + } + + public void selectRun(String runId) { + WebViewPanel.onSwing(() -> { + if (onSelectRun != null) onSelectRun.accept(runId); + }); + } + + public void focusLayer(String layer) { + WebViewPanel.onSwing(() -> { + if (onFocusLayer != null) onFocusLayer.accept(layer); + }); + } + + public void ready() { + WebViewPanel.onSwing(() -> { + if (onReady != null) onReady.run(); + }); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java new file mode 100644 index 00000000..d091b253 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/GherkinEditorHost.java @@ -0,0 +1,61 @@ +package tools.dscode.workbench.ui.web; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.LongConsumer; + +/** JavaScript bridge for the live Gherkin block editor. Does not execute Gherkin. */ +public final class GherkinEditorHost { + private Consumer> onDocument; + private LongConsumer onSeek; + private Runnable onAddStep; + private Runnable onReady; + + public void onDocument(Consumer> onDocument) { + this.onDocument = onDocument; + } + + public void onSeek(LongConsumer onSeek) { + this.onSeek = onSeek; + } + + public void onAddStep(Runnable onAddStep) { + this.onAddStep = onAddStep; + } + + public void onReady(Runnable onReady) { + this.onReady = onReady; + } + + public void documentChanged(String json) { + Map payload = WorkbenchWebJson.readMap(json); + Object raw = payload.get("lines"); + List lines = new ArrayList<>(); + if (raw instanceof List list) { + for (Object item : list) lines.add(item == null ? "" : item.toString()); + } + WebViewPanel.onSwing(() -> { + if (onDocument != null) onDocument.accept(lines); + }); + } + + public void seek(long id) { + WebViewPanel.onSwing(() -> { + if (id >= 0 && onSeek != null) onSeek.accept(id); + }); + } + + public void requestAddStep() { + WebViewPanel.onSwing(() -> { + if (onAddStep != null) onAddStep.run(); + }); + } + + public void ready() { + WebViewPanel.onSwing(() -> { + if (onReady != null) onReady.run(); + }); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java new file mode 100644 index 00000000..6864259b --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/JavaFxSupport.java @@ -0,0 +1,119 @@ +package tools.dscode.workbench.ui.web; + +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.jar.JarFile; + +/** + * Workbench-only JavaFX bootstrap. + * + *

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

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

+  
+ + + diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js new file mode 100644 index 00000000..6c4aa36a --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/diagnostic-explorer.js @@ -0,0 +1,81 @@ +(function () { + var runs = document.getElementById("runs"); + var frame = document.getElementById("frame"); + var step = document.getElementById("step"); + var status = document.getElementById("status"); + var layers = document.getElementById("layers"); + var excerpt = document.getElementById("excerpt"); + var model = { runs: [], frames: [], layers: [], index: 0, playing: false, gap: "" }; + var timer = null; + + function render() { + runs.innerHTML = ""; + model.runs.forEach(function (run) { + var option = document.createElement("option"); + option.value = run.runId; + option.textContent = run.label || run.runId; + if (run.selected) option.selected = true; + runs.appendChild(option); + }); + var current = model.frames[model.index]; + if (current && current.dataUri) { + frame.src = current.dataUri; + frame.style.display = "inline-block"; + step.textContent = current.stepText || ""; + } else { + frame.removeAttribute("src"); + frame.style.display = "none"; + step.textContent = model.gap || "No retained screenshot frames for this run."; + } + status.textContent = model.frames.length + ? ("Frame " + (model.index + 1) + " / " + model.frames.length) + : (model.gap || "No retained diagnostic run"); + layers.innerHTML = ""; + (model.layers || []).forEach(function (layer, index) { + var row = document.createElement("div"); + row.className = "layer" + (layer.present ? "" : " missing"); + row.innerHTML = "" + layer.layer + "" + (layer.present ? "available" : "absent") + ""; + if (layer.present) { + row.addEventListener("click", function () { + excerpt.textContent = layer.excerpt || ""; + if (window.diagnosticHost && window.diagnosticHost.focusLayer) { + window.diagnosticHost.focusLayer(layer.layer); + } + }); + } + layers.appendChild(row); + }); + } + + function show(delta) { + if (!model.frames.length) return; + model.index = (model.index + delta + model.frames.length) % model.frames.length; + render(); + } + + document.getElementById("prev").addEventListener("click", function () { show(-1); }); + document.getElementById("next").addEventListener("click", function () { show(1); }); + document.getElementById("play").addEventListener("click", function () { + model.playing = !model.playing; + this.textContent = model.playing ? "Pause" : "Play"; + if (timer) clearInterval(timer); + if (model.playing) { + timer = setInterval(function () { show(1); }, 1200); + } + }); + runs.addEventListener("change", function () { + if (window.diagnosticHost && window.diagnosticHost.selectRun) { + window.diagnosticHost.selectRun(runs.value); + } + }); + + window.setDiagnosticState = function (json) { + model = typeof json === "string" ? JSON.parse(json) : json; + model.index = model.index || 0; + render(); + }; + + window.onWorkbenchReady = function () { + if (window.diagnosticHost && window.diagnosticHost.ready) window.diagnosticHost.ready(); + }; +})(); diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css new file mode 100644 index 00000000..54f009fc --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css @@ -0,0 +1,96 @@ +:root { + --bg: #f8fafc; + --ink: #111827; + --muted: #6b7280; + --line: #dbe2ea; + --given: #1d4ed8; + --when: #b45309; + --then: #047857; + --and: #334155; + --iff: #be123c; + --play: #fde68a; + --exec: #bfdbfe; + --card: #ffffff; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--ink); + font: 13px/1.45 "Segoe UI", "SF Pro Text", sans-serif; +} +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--line); + background: #fff; +} +.hint { color: var(--muted); } +button { + border: 1px solid var(--line); + background: #fff; + border-radius: 8px; + padding: 5px 10px; + cursor: pointer; +} +.board { + padding: 12px 14px 28px; + min-height: calc(100% - 42px); +} +.block { + background: var(--card); + border: 1px solid var(--line); + border-left: 4px solid var(--and); + border-radius: 10px; + box-shadow: var(--shadow); + margin: 6px 0; + padding: 6px 8px 6px 10px; +} +.block.given { border-left-color: var(--given); } +.block.when { border-left-color: var(--when); } +.block.then { border-left-color: var(--then); } +.block.iff { border-left-color: var(--iff); } +.block.structure { + background: #eef2ff; + border-left-color: #4f46e5; +} +.block.playhead { background: var(--play); } +.block.executing { background: var(--exec); } +.block.drop-target { outline: 2px dashed #2563eb; } +.row { + display: flex; + align-items: flex-start; + gap: 8px; +} +.handle { + cursor: grab; + color: var(--muted); + user-select: none; + padding-top: 4px; +} +.gherkin { + flex: 1; + border: 0; + resize: none; + background: transparent; + font: 13px/1.45 ui-monospace, "Cascadia Mono", Consolas, monospace; + color: inherit; + outline: none; + min-height: 22px; + width: 100%; +} +.children { + margin: 6px 0 2px 22px; + min-height: 8px; + padding-left: 8px; + border-left: 2px solid #e5e7eb; +} +.ghost { opacity: 0.45; } +body.locked .gherkin { caret-color: transparent; } +body.locked .handle { cursor: default; } +body.locked button { opacity: 0.5; pointer-events: none; } +body.locked { cursor: default; } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html new file mode 100644 index 00000000..01911cc8 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.html @@ -0,0 +1,16 @@ + + + + + Live Gherkin Editor + + + +
+ Drag blocks to reorder. Drop on a step to nest. Text stays Gherkin. + +
+
+ + + diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js new file mode 100644 index 00000000..76fcbed5 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js @@ -0,0 +1,238 @@ +(function () { + var board = document.getElementById("board"); + var state = { roots: [], selectedId: null, playheadId: null, executingId: null, suppress: false, locked: false }; + var dragging = null; + + function classify(text) { + var trimmed = String(text || "").replace(/^[\s:]+/, ""); + if (/^(Feature|Rule|Background|Scenario|Scenario Outline|Examples):/.test(trimmed)) return "structure"; + if (/^Given\b/.test(trimmed)) return "given"; + if (/^When\b/.test(trimmed)) return "when"; + if (/^Then\b/.test(trimmed)) return "then"; + if (/^(IF|ELSE|ELSE-IF)\b/.test(trimmed)) return "iff"; + return "and"; + } + + function autosize(area) { + area.style.height = "0px"; + area.style.height = Math.max(22, area.scrollHeight) + "px"; + } + + function render() { + board.innerHTML = ""; + state.roots.forEach(function (block) { + board.appendChild(renderBlock(block)); + }); + } + + function renderBlock(block) { + var card = document.createElement("div"); + card.className = "block " + classify(block.text); + card.dataset.id = String(block.id); + if (block.id === state.playheadId) card.classList.add("playhead"); + if (block.id === state.executingId) card.classList.add("executing"); + card.draggable = !state.locked; + + var row = document.createElement("div"); + row.className = "row"; + var handle = document.createElement("div"); + handle.className = "handle"; + handle.textContent = "⋮⋮"; + var input = document.createElement("textarea"); + input.className = "gherkin"; + input.value = block.text; + input.spellcheck = false; + input.readOnly = !!state.locked; + input.addEventListener("input", function () { + block.text = input.value; + autosize(input); + report(); + }); + input.addEventListener("focus", function () { + state.selectedId = block.id; + reportSeek(); + }); + row.appendChild(handle); + row.appendChild(input); + card.appendChild(row); + + var children = document.createElement("div"); + children.className = "children"; + (block.children || []).forEach(function (child) { + children.appendChild(renderBlock(child)); + }); + card.appendChild(children); + + card.addEventListener("click", function (event) { + if (event.target === input) return; + state.selectedId = block.id; + reportSeek(); + }); + + card.addEventListener("dragstart", function (event) { + if (state.locked) { + event.preventDefault(); + return; + } + dragging = { id: block.id }; + card.classList.add("ghost"); + event.dataTransfer.setData("text/plain", String(block.id)); + event.dataTransfer.effectAllowed = "move"; + }); + card.addEventListener("dragend", function () { + card.classList.remove("ghost"); + dragging = null; + clearDrop(); + }); + card.addEventListener("dragover", function (event) { + event.preventDefault(); + event.stopPropagation(); + card.classList.add("drop-target"); + }); + card.addEventListener("dragleave", function () { + card.classList.remove("drop-target"); + }); + card.addEventListener("drop", function (event) { + event.preventDefault(); + event.stopPropagation(); + card.classList.remove("drop-target"); + var sourceId = Number(event.dataTransfer.getData("text/plain")); + if (!sourceId || sourceId === block.id) return; + var rect = card.getBoundingClientRect(); + var nested = event.clientX > rect.left + 48 && !isStructure(block.text); + moveBlock(sourceId, nested ? block.id : parentId(block.id), insertIndex(block.id, nested, event.clientY, rect)); + }); + setTimeout(function () { autosize(input); }, 0); + return card; + } + + function isStructure(text) { + return classify(text) === "structure"; + } + + function parentId(id) { + var found = findParent(state.roots, id, null); + return found; + } + + function findParent(blocks, id, parent) { + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].id === id) return parent; + var nested = findParent(blocks[i].children || [], id, blocks[i].id); + if (nested !== undefined && nested !== null || (nested === null && findBlock(blocks[i].children || [], id))) { + if (findBlock(blocks[i].children || [], id)) return blocks[i].id; + return nested; + } + } + return null; + } + + function findBlock(blocks, id) { + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].id === id) return blocks[i]; + var nested = findBlock(blocks[i].children || [], id); + if (nested) return nested; + } + return null; + } + + function insertIndex(targetId, nested, clientY, rect) { + if (nested) { + var target = findBlock(state.roots, targetId); + return target && target.children ? target.children.length : 0; + } + return clientY > (rect.top + rect.height / 2) ? indexAmongSiblings(targetId) + 1 : indexAmongSiblings(targetId); + } + + function indexAmongSiblings(id) { + var parent = parentId(id); + var siblings = parent == null ? state.roots : (findBlock(state.roots, parent).children || []); + for (var i = 0; i < siblings.length; i++) { + if (siblings[i].id === id) return i; + } + return siblings.length; + } + + function moveBlock(sourceId, newParentId, index) { + var removed = removeBlock(state.roots, sourceId); + if (!removed) return; + if (newParentId == null) { + state.roots.splice(Math.max(0, Math.min(index, state.roots.length)), 0, removed); + } else { + var parent = findBlock(state.roots, newParentId); + if (!parent || contains(removed, newParentId)) { + state.roots.push(removed); + } else { + parent.children = parent.children || []; + parent.children.splice(Math.max(0, Math.min(index, parent.children.length)), 0, removed); + } + } + render(); + report(); + } + + function contains(block, id) { + if (block.id === id) return true; + return (block.children || []).some(function (child) { return contains(child, id); }); + } + + function removeBlock(blocks, id) { + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].id === id) return blocks.splice(i, 1)[0]; + var nested = removeBlock(blocks[i].children || [], id); + if (nested) return nested; + } + return null; + } + + function flatten(blocks, level, out) { + (blocks || []).forEach(function (block) { + var body = String(block.text || "").replace(/^[\s:]+/, ""); + var line = level > 0 ? (" " + ":".repeat(level) + " " + body) : (block.text || ""); + out.push({ id: block.id, text: line }); + flatten(block.children || [], level + 1, out); + }); + } + + function report() { + if (state.locked || state.suppress || !window.gherkinHost || !window.gherkinHost.documentChanged) return; + var lines = []; + flatten(state.roots, 0, lines); + window.gherkinHost.documentChanged(JSON.stringify({ + lines: lines.map(function (item) { return item.text; }), + selectedId: state.selectedId, + playheadId: state.playheadId + })); + } + + function reportSeek() { + if (state.locked || state.suppress || !window.gherkinHost || !window.gherkinHost.seek) return; + window.gherkinHost.seek(state.selectedId == null ? -1 : state.selectedId); + } + + window.setEditorState = function (json) { + var payload = typeof json === "string" ? JSON.parse(json) : json; + state.suppress = true; + state.roots = payload.roots || []; + state.selectedId = payload.selectedId; + state.playheadId = payload.playheadId; + state.executingId = payload.executingId; + state.locked = !!payload.locked; + document.body.classList.toggle("locked", state.locked); + var add = document.getElementById("add-block"); + if (add) add.disabled = state.locked; + render(); + state.suppress = false; + }; + + document.getElementById("add-block").addEventListener("click", function () { + if (state.locked) return; + if (window.gherkinHost && window.gherkinHost.requestAddStep) { + window.gherkinHost.requestAddStep(); + } + }); + + window.onWorkbenchReady = function () { + if (window.gherkinHost && window.gherkinHost.ready) window.gherkinHost.ready(); + }; +})(); diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css new file mode 100644 index 00000000..16fffec9 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.css @@ -0,0 +1,36 @@ +html, body { + margin: 0; + height: 100%; + background: #f8fafc; + color: #111827; + font: 13px/1.4 "Segoe UI", "SF Pro Text", sans-serif; +} +.toolbar { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid #dbe2ea; + background: #fff; +} +select, input, textarea, button { + border: 1px solid #d7dee7; + border-radius: 8px; + padding: 5px 8px; + font: inherit; +} +.status { color: #6b7280; } +.tree { padding: 12px; } +.row { + display: grid; + grid-template-columns: minmax(120px, 1fr) 140px minmax(160px, 2fr); + gap: 8px; + margin-bottom: 8px; + align-items: start; +} +textarea { + min-height: 34px; + font: 12px/1.4 ui-monospace, Consolas, monospace; + width: 100%; +} +.empty { color: #6b7280; padding: 18px; } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html new file mode 100644 index 00000000..bec91f2e --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.html @@ -0,0 +1,19 @@ + + + + + Mapping + + + +
+ + + Start the live worker to inspect Mapping. +
+
+ + + diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js new file mode 100644 index 00000000..92ddfdf8 --- /dev/null +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js @@ -0,0 +1,112 @@ +(function () { + var maps = document.getElementById("maps"); + var tree = document.getElementById("tree"); + var status = document.getElementById("status"); + var model = { entries: [], properties: [], restorable: false, mapReference: "" }; + + function render() { + maps.innerHTML = ""; + model.entries.forEach(function (entry, index) { + var option = document.createElement("option"); + option.value = entry.reference; + option.textContent = entry.label; + if (entry.reference === model.mapReference) option.selected = true; + maps.appendChild(option); + if (!model.mapReference && index === 0) option.selected = true; + }); + tree.innerHTML = ""; + if (!model.properties.length) { + var empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = model.entries.length + ? "This NodeMap has no properties yet." + : "No NodeMaps are available in the current ParsingMap."; + tree.appendChild(empty); + return; + } + model.properties.forEach(function (property) { + tree.appendChild(row(property)); + }); + } + + function row(property) { + var wrap = document.createElement("div"); + wrap.className = "row"; + var key = document.createElement("input"); + key.value = property.key; + key.disabled = !model.restorable || !!model.locked; + var type = document.createElement("select"); + ["string", "numeric", "boolean", "object-as-json", "object-as-xml"].forEach(function (name) { + var option = document.createElement("option"); + option.value = name; + option.textContent = name; + if (name === property.type) option.selected = true; + type.appendChild(option); + }); + type.disabled = !model.restorable || !!model.locked; + var value = document.createElement("textarea"); + value.value = property.text; + value.disabled = !model.restorable || !!model.locked; + function commit() { + if (!window.mappingHost || !window.mappingHost.propertyChanged) return; + window.mappingHost.propertyChanged(JSON.stringify({ + mapReference: model.mapReference, + oldKey: property.key, + key: key.value, + type: type.value, + text: value.value + })); + property.key = key.value; + property.type = type.value; + property.text = value.value; + } + key.addEventListener("change", commit); + type.addEventListener("change", commit); + value.addEventListener("change", commit); + wrap.appendChild(key); + wrap.appendChild(type); + wrap.appendChild(value); + return wrap; + } + + maps.addEventListener("change", function () { + if (model.locked) return; + if (window.mappingHost && window.mappingHost.selectMap) { + window.mappingHost.selectMap(maps.value); + } + }); + document.getElementById("add").addEventListener("click", function () { + if (!model.restorable || model.locked) return; + var key = "newProperty"; + var n = 1; + while (model.properties.some(function (item) { return item.key === key; })) { + key = "newProperty" + (++n); + } + model.properties.push({ key: key, type: "string", text: "" }); + render(); + if (window.mappingHost && window.mappingHost.propertyChanged) { + window.mappingHost.propertyChanged(JSON.stringify({ + mapReference: model.mapReference, + oldKey: "", + key: key, + type: "string", + text: "" + })); + } + }); + + window.setMappingState = function (json) { + model = typeof json === "string" ? JSON.parse(json) : json; + status.textContent = model.status || ""; + document.body.classList.toggle("locked", !!model.locked); + var add = document.getElementById("add"); + if (add) add.disabled = !!model.locked || !model.restorable; + var mapsEl = document.getElementById("maps"); + if (mapsEl) mapsEl.disabled = !!model.locked; + render(); + }; + + window.onWorkbenchReady = function () { + if (window.mappingHost && window.mappingHost.ready) window.mappingHost.ready(); + }; +})(); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java new file mode 100644 index 00000000..0d60e6a8 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java @@ -0,0 +1,168 @@ +package tools.dscode.workbench; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; +import tools.dscode.workbench.player.WorkbenchSaveResult; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchControllerLeaseTest { + @TempDir + Path project; + + @Test + void agentMutatingCallsFailUntilTheyHoldTheLease() { + try (WorkbenchController controller = new WorkbenchController(project)) { + IllegalStateException denied = assertThrows( + IllegalStateException.class, + () -> WorkbenchCallContext.runAs( + WorkbenchLeaseHolder.AGENT, + () -> controller.replaceLiveDocument(List.of("Given stay")) + ) + ); + assertTrue(denied.getMessage().contains("workbench_request_control")); + + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + controller.setCurrentAction("Editing the live buffer."); + controller.replaceLiveDocument(List.of("Scenario: Live", " Given stay")); + }); + assertTrue(controller.playerState().documentText().contains("Given stay")); + assertEquals("Copilot", controller.controlLeaseSnapshot().agentDisplayName()); + } + } + + @Test + void takeControlUnlocksHumanAndCancelsAgentSaveWaitWithoutWriting() throws Exception { + Path feature = project.resolve("src/test/resources/features/keep.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Keep + Scenario: Original + Given original + """); + String original = Files.readString(feature); + + try (WorkbenchController controller = new WorkbenchController(project)) { + controller.attachUi(); + controller.loadPickerScenario( + List.of("Feature: Keep", "", "Scenario: Original", " Given original", " And extra"), + feature, + "Original", + 2, + 3 + ); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + Thread saver = new Thread(() -> { + result.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return controller.requestSave(); + })); + }, "controller-save-wait"); + saver.setDaemon(true); + saver.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + + controller.takeControl(); + saver.join(2_000); + assertFalse(result.get().written()); + assertEquals("CANCELLED", result.get().status()); + assertEquals(original, Files.readString(feature)); + assertTrue(controller.controlLeaseSnapshot().humanHolds()); + } + } + + @Test + void deniedSaveDoesNotWriteAndApprovedSaveCopiesTheLiveScenario() throws Exception { + Path feature = project.resolve("src/test/resources/features/login.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Sign in + Scenario: Valid password + Given a user + """); + + try (WorkbenchController controller = new WorkbenchController(project)) { + controller.attachUi(); + controller.loadPickerScenario( + List.of("Feature: Sign in", "", "Scenario: Valid password", " Given a user", " And extra"), + feature, + "Valid password", + 2, + 3 + ); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference denied = new AtomicReference<>(); + Thread saver = new Thread(() -> { + denied.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return controller.requestSave(); + })); + }, "controller-save-deny"); + saver.setDaemon(true); + saver.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + String requestId = controller.controlLeaseSnapshot().pendingPermission().id(); + controller.answerPermission(requestId, false); + saver.join(2_000); + assertEquals("DENIED", denied.get().status()); + assertFalse(Files.readString(feature).contains("And extra")); + + CountDownLatch waitingAllow = new CountDownLatch(1); + AtomicReference allowed = new AtomicReference<>(); + Thread allowSaver = new Thread(() -> { + allowed.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waitingAllow.countDown(); + return controller.requestSave(); + })); + }, "controller-save-allow"); + allowSaver.setDaemon(true); + allowSaver.start(); + assertTrue(waitingAllow.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + controller.answerPermission(controller.controlLeaseSnapshot().pendingPermission().id(), true); + allowSaver.join(2_000); + assertTrue(allowed.get().written()); + assertTrue(Files.readString(feature).contains("And extra")); + } + } + + @Test + void demoCommitSaveStaysUnsavable() { + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchSaveResult result = controller.commitSave(); + assertFalse(result.written()); + assertEquals("UNSAVABLE", result.status()); + } + } + + private static void waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("Timed out waiting for controller lease condition."); + } + Thread.sleep(10); + } + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/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/catalog/ConsumerFeatureCatalogTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java new file mode 100644 index 00000000..7c6b9944 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java @@ -0,0 +1,243 @@ +package tools.dscode.workbench.catalog; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.workbench.sync.WorkbenchManifest; +import tools.dscode.workbench.sync.WorkbenchSyncMode; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConsumerFeatureCatalogTest { + @TempDir + Path project; + + @Test + void discoversFeaturesFromProjectOwnedTestResourcesAndSupportsSelectionSearch() throws Exception { + Path features = project.resolve("src/test/resources/features"); + Files.createDirectories(features.resolve("workflow")); + Files.writeString(features.resolve("login.feature"), """ + Feature: Sign in + Scenario: Valid password + Given a user + Scenario: Locked account + Given a lock + """); + Files.writeString(features.resolve("workflow/nested.feature"), """ + Feature: Nested workflow + Scenario: IF ELSE branch + * IF: true + : * Then stay + """); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, null); + assertEquals(2, catalog.features().size()); + assertEquals("Nested workflow", catalog.featuresForBrowse().stream() + .filter(feature -> feature.featureName().equals("Nested workflow")) + .findFirst() + .orElseThrow() + .featureName()); + + catalog.setBrowseMode(ConsumerFeatureCatalog.BrowseMode.FILE_PATH); + assertTrue(catalog.featuresForBrowse().stream() + .anyMatch(feature -> feature.browseLabel(catalog.browseMode()).contains("workflow/nested.feature"))); + + assertEquals(3, catalog.visibleScenarios().size()); + + catalog.toggleFeature(features.resolve("login.feature")); + assertEquals(2, catalog.visibleScenarios().size()); + assertTrue(catalog.visibleScenarios().stream().allMatch(scenario -> scenario.featureName().equals("Sign in"))); + + catalog.filter().setNameQuery("locked"); + assertEquals(1, catalog.visibleScenarios().size()); + assertEquals("Locked account", catalog.visibleScenarios().getFirst().name()); + + catalog.clearFeatureSelection(); + catalog.filter().setNameQuery("if else"); + assertEquals(1, catalog.visibleScenarios().size()); + assertEquals("IF ELSE branch", catalog.visibleScenarios().getFirst().name()); + assertTrue(catalog.visibleScenarios().getFirst().lines().getFirst().startsWith("Feature:")); + } + + @Test + void nameTagAndOptionalFeatureFiltersComposeAndInheritFeatureTags() throws Exception { + Path features = project.resolve("src/test/resources/features"); + Files.createDirectories(features); + Files.writeString(features.resolve("auth.feature"), """ + @feature-auth @shared + Feature: Sign in + + @smoke @login + Scenario: Valid password + Given a user + + @wip + Scenario: Locked account + Given a lock + + @outline + Scenario Outline: Search term + When search + @fast + Examples: + | term | + | a | + @slow @db + Examples: + | term | + | b | + """); + Files.writeString(features.resolve("other.feature"), """ + @other + Feature: Other + + Scenario: Unrelated + Given stay + """); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, null); + assertEquals(4, catalog.candidateScenarios().size()); + assertEquals(4, catalog.visibleScenarios().size()); + + ConsumerFeatureCatalog.ScenarioEntry valid = named(catalog, "Valid password"); + assertEquals(List.of("feature-auth", "shared", "smoke", "login"), valid.effectiveTags()); + assertEquals(List.of("smoke", "login"), valid.tags()); + + ConsumerFeatureCatalog.ScenarioEntry locked = named(catalog, "Locked account"); + assertTrue(locked.effectiveTags().contains("feature-auth")); + assertTrue(locked.effectiveTags().contains("wip")); + + ConsumerFeatureCatalog.ScenarioEntry search = named(catalog, "Search term"); + assertTrue(search.effectiveTags().containsAll(List.of("feature-auth", "shared", "outline", "fast", "slow", "db"))); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.STARTS_WITH); + catalog.filter().setNameQuery("valid"); + assertEquals(List.of("Valid password"), names(catalog)); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.ENDS_WITH); + catalog.filter().setNameQuery("account"); + assertEquals(List.of("Locked account"), names(catalog)); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.FULL_MATCH); + catalog.filter().setNameQuery("search term"); + assertEquals(List.of("Search term"), names(catalog)); + + catalog.filter().setNameMatchMode(ScenarioFilter.NameMatchMode.CONTAINS); + catalog.filter().setNameQuery(""); + catalog.filter().setIncludeTagsQuery("@smoke @login"); + assertEquals(List.of("Valid password"), names(catalog)); + + catalog.filter().setIncludeTagsQuery(""); + catalog.filter().setExcludeTagsQuery("wip"); + assertEquals(List.of("Unrelated", "Search term", "Valid password"), names(catalog)); + + catalog.filter().setIncludeTagsQuery("shared"); + catalog.filter().setExcludeTagsQuery("@wip"); + assertEquals(List.of("Search term", "Valid password"), names(catalog)); + + catalog.filter().setIncludeTagsQuery("@slow"); + catalog.filter().setExcludeTagsQuery(""); + assertEquals(List.of("Search term"), names(catalog)); + + catalog.filter().setIncludeTagsQuery(""); + catalog.filter().setNameQuery("lock"); + assertEquals(List.of("Locked account"), names(catalog)); + + catalog.clearFeatureSelection(); + catalog.filter().setNameQuery(""); + catalog.toggleFeature(features.resolve("other.feature")); + assertEquals(List.of("Unrelated"), names(catalog)); + assertEquals(1, catalog.candidateScenarios().size()); + + catalog.clearFeatureSelection(); + assertEquals(4, catalog.candidateScenarios().size()); + assertEquals(4, catalog.visibleScenarios().size()); + } + + @Test + void ruleTagsAreInheritedWithFeatureTags() throws Exception { + Path features = project.resolve("src/test/resources/features"); + Files.createDirectories(features); + Files.writeString(features.resolve("rules.feature"), """ + @feature-tag + Feature: Rules + + @rule-a + Rule: First + + @own + Scenario: Inside rule + Given stay + """); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, null); + ConsumerFeatureCatalog.ScenarioEntry inside = named(catalog, "Inside rule"); + assertEquals(List.of("own"), inside.tags()); + assertTrue(inside.effectiveTags().containsAll(List.of("feature-tag", "rule-a", "own"))); + catalog.filter().setIncludeTagsQuery("feature-tag rule-a own"); + assertEquals(List.of("Inside rule"), names(catalog)); + } + + private static ConsumerFeatureCatalog.ScenarioEntry named(ConsumerFeatureCatalog catalog, String name) { + return catalog.visibleScenarios().stream() + .filter(scenario -> scenario.name().equals(name)) + .findFirst() + .orElseThrow(); + } + + private static List names(ConsumerFeatureCatalog catalog) { + return catalog.visibleScenarios().stream() + .map(ConsumerFeatureCatalog.ScenarioEntry::name) + .toList(); + } + + @Test + void prefersClasspathFeaturesConfigurationWithoutCrawlingUnrelatedDirectories() throws Exception { + Files.createDirectories(project.resolve("src/test/resources/features")); + Files.writeString(project.resolve("src/test/resources/features/configured.feature"), """ + Feature: Configured + Scenario: Only this + Given stay in project features + """); + Files.createDirectories(project.resolve("unrelated/features")); + Files.writeString(project.resolve("unrelated/features/outside.feature"), """ + Feature: Outside + Scenario: Must not appear + Given ignored + """); + Files.createDirectories(project.resolve("src/test/resources")); + Files.writeString(project.resolve("src/test/resources/pickleball.properties"), + "pkb_features=classpath:features\n"); + + WorkbenchManifest manifest = new WorkbenchManifest( + WorkbenchManifest.CURRENT_SCHEMA, + project.toString(), + "MAVEN", + "mvn", + List.of(project.resolve("src/test/resources").toString()), + List.of(), + List.of(), + project.resolve("live").toString(), + "2026-01-01T00:00:00Z", + "fp", + List.of(), + "2.1.9", + "21", + "/usr/lib/jvm", + WorkbenchSyncMode.FULL.name(), + "java-fp", + "resource-fp", + "build-fp", + "dep-fp" + ); + + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(project, manifest); + assertEquals(1, catalog.features().size()); + assertEquals("Configured", catalog.features().getFirst().featureName()); + assertEquals("Only this", catalog.visibleScenarios().getFirst().name()); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java new file mode 100644 index 00000000..e21c5e1b --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ScenarioFilterTest.java @@ -0,0 +1,125 @@ +package tools.dscode.workbench.catalog; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScenarioFilterTest { + @Test + void nameMatchModesAreCaseInsensitiveAndDefaultToContains() { + ScenarioFilter filter = new ScenarioFilter(); + assertEquals(ScenarioFilter.NameMatchMode.CONTAINS, filter.nameMatchMode()); + + ConsumerFeatureCatalog.ScenarioEntry login = scenario("Valid Login User"); + ConsumerFeatureCatalog.ScenarioEntry logout = scenario("Logout path"); + List pool = List.of(login, logout); + + filter.setNameQuery("LOGIN"); + assertEquals(List.of(login), filter.apply(pool)); + + filter.setNameMatchMode(ScenarioFilter.NameMatchMode.STARTS_WITH); + filter.setNameQuery("valid"); + assertEquals(List.of(login), filter.apply(pool)); + filter.setNameQuery("user"); + assertTrue(filter.apply(pool).isEmpty()); + + filter.setNameMatchMode(ScenarioFilter.NameMatchMode.ENDS_WITH); + filter.setNameQuery("USER"); + assertEquals(List.of(login), filter.apply(pool)); + filter.setNameQuery("valid"); + assertTrue(filter.apply(pool).isEmpty()); + + filter.setNameMatchMode(ScenarioFilter.NameMatchMode.FULL_MATCH); + filter.setNameQuery("valid login user"); + assertEquals(List.of(login), filter.apply(pool)); + filter.setNameQuery("valid login"); + assertTrue(filter.apply(pool).isEmpty()); + } + + @Test + void includeTagsAreAndAndExcludeTagsAreNot() { + ConsumerFeatureCatalog.ScenarioEntry smokeLogin = scenario("A", "smoke", "login"); + ConsumerFeatureCatalog.ScenarioEntry smokeOnly = scenario("B", "smoke"); + ConsumerFeatureCatalog.ScenarioEntry wip = scenario("C", "wip", "login"); + List pool = List.of(smokeLogin, smokeOnly, wip); + + ScenarioFilter filter = new ScenarioFilter(); + filter.setIncludeTagsQuery("@smoke @login"); + assertEquals(List.of(smokeLogin), filter.apply(pool)); + + filter.setIncludeTagsQuery("smoke, login"); + assertEquals(List.of(smokeLogin), filter.apply(pool)); + + filter.setIncludeTagsQuery(""); + filter.setExcludeTagsQuery("@wip"); + assertEquals(List.of(smokeLogin, smokeOnly), filter.apply(pool)); + + filter.setIncludeTagsQuery("login"); + filter.setExcludeTagsQuery("wip"); + assertEquals(List.of(smokeLogin), filter.apply(pool)); + } + + @Test + void emptyNameAndTagFieldsImposeNoConstraint() { + ConsumerFeatureCatalog.ScenarioEntry one = scenario("One", "alpha"); + ConsumerFeatureCatalog.ScenarioEntry two = scenario("Two"); + ScenarioFilter filter = new ScenarioFilter(); + assertEquals(List.of(one, two), filter.apply(List.of(one, two))); + } + + @Test + void nameAndTagFiltersCompose() { + ConsumerFeatureCatalog.ScenarioEntry keep = scenario("Locked account", "wip"); + ConsumerFeatureCatalog.ScenarioEntry otherWip = scenario("Other wip", "wip"); + ConsumerFeatureCatalog.ScenarioEntry lockedClean = scenario("Locked account clean"); + ScenarioFilter filter = new ScenarioFilter(); + filter.setNameQuery("locked"); + filter.setIncludeTagsQuery("wip"); + assertEquals(List.of(keep), filter.apply(List.of(keep, otherWip, lockedClean))); + } + + @Test + void parseTagQueryAcceptsAtSignCommaAndWhitespace() { + assertEquals(List.of("smoke", "login", "wip"), + ScenarioFilter.parseTagQuery(" @smoke, login wip ")); + assertTrue(ScenarioFilter.parseTagQuery("").isEmpty()); + assertTrue(ScenarioFilter.parseTagQuery(" , ").isEmpty()); + assertEquals("smoke", ScenarioFilter.canonicalTag("@@smoke")); + } + + @Test + void tagComparisonIsCaseSensitiveAfterCanonicalizingAt() { + ConsumerFeatureCatalog.ScenarioEntry tagged = scenario("A", "Smoke"); + ScenarioFilter filter = new ScenarioFilter(); + filter.setIncludeTagsQuery("Smoke"); + assertEquals(List.of(tagged), filter.apply(List.of(tagged))); + filter.setIncludeTagsQuery("smoke"); + assertTrue(filter.apply(List.of(tagged)).isEmpty()); + } + + @Test + void gherkinTagLinesRequireAtPrefixedTokens() { + assertTrue(ScenarioFilter.isGherkinTagLine("@smoke @login")); + assertFalse(ScenarioFilter.isGherkinTagLine("Given @not-a-tag-line")); + assertEquals(List.of("smoke", "login"), ScenarioFilter.parseGherkinTagLine("@smoke @login")); + } + + private static ConsumerFeatureCatalog.ScenarioEntry scenario(String name, String... effectiveTags) { + return new ConsumerFeatureCatalog.ScenarioEntry( + name, + "Demo", + Path.of("demo.feature"), + "demo.feature", + 1, + 3, + List.of("Scenario: " + name), + List.of(), + List.of(effectiveTags) + ); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java new file mode 100644 index 00000000..3cba86ff --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java @@ -0,0 +1,90 @@ +package tools.dscode.workbench.diagnostics; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DiagnosticEvidenceNavigatorTest { + @TempDir + Path project; + + @Test + void readsCatalogThenIndexAndScreenshotFramesWithoutInventingRuns() throws Exception { + Path root = project.resolve("reports/diagnostic-runs"); + Path run = root.resolve("run-1"); + Path scenario = run.resolve("scenarios/scenario-1"); + Path shots = scenario.resolve("screenshots"); + Files.createDirectories(shots); + Files.writeString(root.resolve("run-catalog.json"), """ + {"runs":[{"runId":"run-1","outcome":"PASSED"}]} + """); + Files.writeString(run.resolve("run-index.json"), """ + {"runId":"run-1","outcome":"PASSED"} + """); + Files.writeString(scenario.resolve("summary.json"), """ + {"lastStepText":"Then stay"} + """); + Files.writeString(scenario.resolve("events.jsonl"), """ + {"stepText":"Given navigate to: URL.home"} + {"stepText":"Then stay"} + """); + Path png = shots.resolve("frame-1.png"); + Files.write(png, new byte[]{1, 2, 3}); + + DiagnosticEvidenceNavigator navigator = new DiagnosticEvidenceNavigator(project); + assertTrue(navigator.available()); + assertEquals("run-1", navigator.catalogRuns().getFirst().runId()); + + DiagnosticEvidenceNavigator.Timeline timeline = navigator.timeline(run); + assertEquals(1, timeline.frames().size()); + assertEquals("Given navigate to: URL.home", timeline.frames().getFirst().stepText()); + + assertTrue(navigator.layers(run, "scenario-1").stream() + .anyMatch(layer -> layer.layer() == DiagnosticEvidenceNavigator.Layer.EVENTS && layer.present())); + assertTrue(new DiagnosticEvidenceNavigator(project, project.resolve("missing")).catalogRuns().isEmpty()); + } + + @Test + void sparseReadersReturnCatalogIndexClustersAndSummaryWithoutEventsOrScreenshots() throws Exception { + Path root = project.resolve("reports/diagnostic-runs"); + Path run = root.resolve("run-1"); + Path scenario = run.resolve("scenarios/scenario-1"); + Files.createDirectories(scenario.resolve("screenshots")); + Files.writeString(root.resolve("run-catalog.json"), """ + {"runs":[{"runId":"run-1","outcome":"FAILED"}]} + """); + Files.writeString(run.resolve("run-index.json"), """ + {"runId":"run-1","outcome":"FAILED","scenarioCount":1} + """); + Files.writeString(run.resolve("clusters.json"), """ + {"clusters":[{"id":"c1","size":1}]} + """); + Files.writeString(scenario.resolve("summary.json"), """ + {"scenarioId":"scenario-1","outcome":"FAILED","lastStepText":"Then stay"} + """); + Files.writeString(scenario.resolve("events.jsonl"), "{\"stepText\":\"secret-event\"}\n"); + Files.write(scenario.resolve("screenshots/frame-1.png"), new byte[]{9, 9, 9}); + + DiagnosticEvidenceNavigator navigator = new DiagnosticEvidenceNavigator(project); + String catalog = navigator.catalogDocument().toString(); + assertTrue(catalog.contains("run-1")); + assertFalse(catalog.contains("secret-event")); + + String runDocument = navigator.runDocument("run-1").toString(); + assertTrue(runDocument.contains("FAILED")); + assertTrue(runDocument.contains("\"clusters\"")); + assertFalse(runDocument.contains("secret-event")); + assertFalse(runDocument.contains("frame-1.png")); + + String summary = navigator.scenarioSummaryDocument("run-1", "scenario-1").toString(); + assertTrue(summary.contains("Then stay")); + assertFalse(summary.contains("secret-event")); + assertFalse(summary.contains("frame-1.png")); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java new file mode 100644 index 00000000..e141d8d3 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/InvestigationHandoffTest.java @@ -0,0 +1,114 @@ +package tools.dscode.workbench.diagnostics; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.control.protocol.InvestigationHandoff; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InvestigationHandoffTest { + @TempDir + Path project; + + @Test + void htmlEscapesCauseAndCapsScreenshotsAndNotesMissingImages() throws Exception { + Path run = project.resolve("reports/diagnostic-runs/run-1/scenarios/s1/screenshots"); + Files.createDirectories(run); + Path present = run.resolve("frame-1.png"); + Files.write(present, new byte[]{1, 2, 3}); + + Map raw = new LinkedHashMap<>(); + raw.put("pkb_investigation_id", "checkout-217"); + raw.put("createdAt", "2026-08-25T06:00:00Z"); + raw.put("scenario", Map.of( + "name", "Submit
", + "feature", "features/forms.feature", + "scenarioId", "scenario-1" + )); + raw.put("outcome", "CAUSE_ONLY"); + raw.put("cause", "Selector