diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 0adb407d..eb794478 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 1a082cd2..86a1c5f4 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -478,13 +478,27 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` @@ -521,9 +535,12 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` @@ -531,6 +548,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchLiveSessionTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java` diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md index 1b9c6763..d4a79d35 100644 --- a/docs/pickleball-workbench-player.md +++ b/docs/pickleball-workbench-player.md @@ -35,7 +35,7 @@ The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Block Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. -The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario. The default demo is a small browser scenario against the Maven consumer local test site: +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario and confirm the copy. The default demo is a small browser scenario against the Maven consumer local test site: ```gherkin Feature: Workbench Live Scenario @@ -107,6 +107,17 @@ The Terminal tails the worker stdout/stderr files Workbench already creates unde The explorer is a rewind/play/focus timeline of retained Pickleball diagnostic runs. Screenshot frames are shown with the Gherkin step that was running when they were taken. Denser layers follow the repository evidence order and only open when the retained files exist. If `reports/diagnostic-runs/run-catalog.json` is missing, the panel stays empty and says so. +## Watched-agent control lease + +The live player is a collaborative testing space, not a second editor. Swing and an attached agent share one `LiveScenarioPlayer` in the Workbench controller. + +- A human can work alone: edit/play the live buffer, then **Save** asks before copying into the original scenario in the original `.feature` file. +- An agent attaches to the running UI through `.pickleball/workbench/attach.json` (localhost JSON tools over the same `WorkbenchServices`). It must not start a second Workbench JVM or worker. +- After `workbench_request_control`, Swing play/edit/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. +- `workbench_request_save` is the only original-feature write path for the agent. With the UI attached it blocks on Allow/Deny. Deny writes nothing. Headless stdio MCP may hold the lease without a banner; Save is still an explicit tool. + +See [pickleball-workbench.md](pickleball-workbench.md) for attach discovery, tool names, and stdout rules. + ## Focused validation The included consumer `@control-bridge` scenario verifies: @@ -115,7 +126,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, and the non-empty browser demo seed. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 5b8fa58d..e63e0903 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -22,7 +22,7 @@ Workbench owns synchronization, `.pickleball/workbench/` disposable state, worke `pickleball-control-protocol` owns only immutable wire records, request/response envelopes, transport constants, capabilities, and version/minimum-version negotiation. Worker-side bridge server/coordinator/bootstrap and all translation to runtime operations remain in Pickleball core. -MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. +MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. A visible UI keeps one Workbench JVM and one consumer worker. An AI agent attaches to that live session through a localhost HTTP JSON facade; it must not start a second Workbench or a second worker. The canonical worker bridge environment is: @@ -149,7 +149,7 @@ The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level - playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live; **Save** writes back to the originating `.feature` file when one was loaded. The Workbench-owned demo has no save path. +The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live. **Save** is confirmation-gated: it copies the live scenario into the originating `.feature` file and scenario only after Allow. The Workbench-owned demo has no save path. Workbench never writes `.feature` files on picker load or on Deny. The playhead behaves like an audio-player needle: @@ -242,6 +242,63 @@ Synchronization, worker actions, live bridge calls, Mapping operations, event re The UI is intentionally not a project IDE, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. The Live Scenario Editor is a session-scoped Gherkin player/editor, not a workspace file explorer and not an automatic writer of consumer `.feature` files. +### Watched AI-agent control lease + +Workbench owns one control lease for the live session. Swing and MCP/HTTP adapters share it; the lease is not Swing-only state. + +- Holder is `HUMAN` when the UI is up, or `AGENT` after an attached agent requests control. +- The snapshot also carries the agent display name, `currentAction` banner text, and at most one pending permission request. + +While the human holds the lease, Swing play/edit/mapping/save/worker controls stay enabled. Agent mutating calls fail clearly until `workbench_request_control`. + +While an agent holds the lease, the human can watch the same window. Play, edit, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. + +The agent should update `currentAction` as it works. Playhead, Mapping, Terminal, and screenshots follow because the agent uses the same `LiveScenarioPlayer` / worker as the UI. Testing the live scenario (`executeStep`, play, Mapping reads, evidence) is allowed on the agent lease. Copying the live scenario into the original `.feature` is not; that goes through `workbench_request_save` and waits for Allow/Deny in the Swing banner. + +Human **Save** uses the same service. After a picker scenario was loaded, Swing asks: copy these live steps into file X / scenario Y? Deny writes nothing. The demo buffer stays unsavable. + +### Attaching an agent to a visible UI + +UI mode cannot share process stdout with stdio MCP. Starting `mcp` while the UI is already running would be a second Workbench JVM. Instead, `ui` starts a 127.0.0.1-only JSON attach endpoint over the same `WorkbenchServices` / `WorkbenchMcpTools` methods and writes disposable discovery state: + +```text +.pickleball/workbench/attach.json +``` + +Example: + +```json +{ + "url": "http://127.0.0.1:51234", + "token": "hex-session-token", + "pid": 12345, + "project": "/path/to/maven-consumer-project", + "mode": "ui-attach", + "bind": "127.0.0.1" +} +``` + +A Copilot or other MCP-style client finds that file in the consumer project, then: + +1. `GET {url}/health` — liveness, no token. +2. `GET {url}/lease` and `GET {url}/player` — `Authorization: Bearer ` or `X-Workbench-Token`. +3. `POST {url}/tools/workbench_request_control` with `{"agentName":"Copilot"}`. +4. Use the existing live tools (`workbench_execute_step`, Mapping, evidence, worker) while holding the lease, and `workbench_set_current_action` so the human can watch. +5. `POST {url}/tools/workbench_request_save` to ask to copy the live scenario into the original feature. The call blocks until the human clicks Allow or Deny, or Take control. + +Headless `java -jar pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. + +From `maven-consumer-project`: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +Then point the agent at `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session. + ## MCP stdio Start the lightweight non-Spring MCP server for a synchronized consumer project: @@ -283,9 +340,16 @@ workbench_worker_stop workbench_worker_status ``` -Live runtime and Mapping: +Live runtime, Mapping, and watched-agent control: ```text +workbench_request_control +workbench_release_control +workbench_set_current_action +workbench_control_lease +workbench_player_state +workbench_player_replace_document +workbench_request_save workbench_execute_step workbench_mapping_get workbench_mapping_put @@ -324,6 +388,8 @@ workbench_step_override_clear `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. +Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. + Controller/runtime failures are returned as MCP tool results with `isError=true`. They are not printed as arbitrary protocol output. ## Scope boundary @@ -381,6 +447,10 @@ Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-c 12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. 13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. 14. Verify blocking runtime actions leave the Swing UI responsive. +15. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. +16. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/mapping/save/worker controls. **Take control** remains enabled. +17. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. +18. The default demo remains unsavable for both human Save and agent `workbench_request_save`. ## Regression diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 76fc81a4..5ceeedb4 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -24,9 +24,9 @@ Workbench-only dependencies, including Jackson and the MCP SDK, belong only on t ## Runtime ownership -The Workbench controller owns synchronization, worker process/session lifecycle, bridge client behavior, MCP stdio, and the thin Swing UI. Pickleball owns consumer-worker behavior such as the bridge server/coordinator, DynamicControl/Gherkin execution, Step Override runtime, Mapping state, browser/service-call access, and woven Cucumber integration. +The Workbench controller owns synchronization, worker process/session lifecycle, bridge client behavior, MCP stdio, the localhost UI-attach endpoint, the watched-agent control lease, and the thin Swing UI. Pickleball owns consumer-worker behavior such as the bridge server/coordinator, DynamicControl/Gherkin execution, Step Override runtime, Mapping state, browser/service-call access, and woven Cucumber integration. -`WorkbenchServices` is the shared plain-Java adapter boundary. `WorkbenchController` composes synchronization and `WorkbenchLiveSession`; MCP and Swing must delegate to that service surface instead of implementing their own worker ownership, bridge calls, Mapping semantics, Step Override behavior, or scenario retry rules. +`WorkbenchServices` is the shared plain-Java adapter boundary. `WorkbenchController` composes synchronization, `WorkbenchLiveSession`, `LiveScenarioPlayer`, and the control lease; MCP, HTTP attach, and Swing must delegate to that service surface instead of implementing their own worker ownership, bridge calls, Mapping semantics, Step Override behavior, scenario retry rules, or a second live Gherkin document. The Workbench bridge client uses only `tools.dscode.control.protocol.*`. Worker-side `ControlBridgeRuntime`, `ControlBridgeCoordinator`, bootstrap, adapters, step compilation, mappings, and execution semantics stay in Pickleball. Workbench may hold the worker entry-point class name as `ControlProtocol.WORKER_MAIN_CLASS`; it must never import or load that class. @@ -75,7 +75,7 @@ Low-level lifecycle controls live under the Session menu and existing investigat `LiveScenarioPlayer` owns presentation/session-buffer state only: stable line IDs, the editable Gherkin document, selected line, playhead, and `STOPPED` / `PAUSED` / `RUNNING` / `WAITING_FOR_STEP`. It must remain headless-testable and must not parse/execute Pickleball steps, implement runtime rewind, model Mapping inheritance, or become Swing component state. -The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless the user uses explicit **Save**. WebView JavaScript must not execute Gherkin. +The playhead is the user-visible needle. Clicking a scenario line instantly seeks it. Global Play always starts from the first executable step in a fresh worker context, not from the playhead. The Live Scenario Editor is an in-place Gherkin document presented as snap-together blocks whose text is Gherkin, including `Given` / `When` / `Then`. Users may edit any block, including previously executed text. The picker loads consumer scenarios into the live buffer. The default buffer is a Workbench-owned browser demo against `URL.home`. Workbench does not write `.feature` files unless **Save** is explicitly approved. Human Save asks before copying the live scenario into the original scenario in the original `.feature` file. An attached agent must use `workbench_request_save` and wait for Allow/Deny when the UI is present. Deny and Take control write nothing. WebView JavaScript must not execute Gherkin. The Step Editor has two play actions: **Step** executes only the editor text through `WorkbenchServices.executeStep` and leaves automatic playback paused; **From Here** restarts into a fresh scenario context and runs from the selected/playhead step through the rest of the buffer. Enter while waiting at end appends the step and continues the live run. Do not strip Gherkin keywords or add a Swing-side step matcher. Worker-side `DynamicControl` / `GherkinControl` remain the only Gherkin interpreters. @@ -103,7 +103,9 @@ Workbench provides: java -jar pickleball-workbench-.jar mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, and Step Override authoring through `WorkbenchServices`. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, and gated Save through `WorkbenchServices`. + +UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. MCP stdout is a hard protocol boundary. `WorkbenchApplication` reserves the original process stdout for the stdio transport and redirects ordinary `System.out` output to stderr before constructing the MCP SDK/controller. The executable must explicitly remain alive for the stdio session until stdin reaches EOF; do not rely on MCP SDK worker-thread liveness to keep the JVM running. Workbench diagnostic text must use stderr or `.pickleball/workbench/logs/`; worker stdout/stderr remain separately redirected to worker log files. No banner, normal log, worker output, test output, or diagnostic chatter may be written to MCP stdout. diff --git a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md index 309dc95b..31bd0270 100644 --- a/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md +++ b/pickleball-workbench/WORKBENCH-PLAYER-CONTEXT.md @@ -10,7 +10,7 @@ This file records the live-player behavior added on top of those unchanged bound The playhead is the user-visible needle. Clicking a scenario line instantly seeks it, like clicking a waveform. Global Play ignores the playhead and always starts from the first executable buffer step in a fresh interactive worker context. -The Live Scenario Editor is an in-place Gherkin document shown as WebView blocks whose text is Gherkin. Users can type at any block, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files unless the user uses explicit Save. The left rail picks features/scenarios from the synchronized consumer project. +The Live Scenario Editor is an in-place Gherkin document shown as WebView blocks whose text is Gherkin. Users can type at any block, including previously executed text. Stable line identities are preserved for same-index edits. The buffer is session-owned and is not written back to consumer `.feature` files unless **Save** is explicitly approved. Human Save confirms before copying into the original scenario. An attached AI agent can request the controller-owned control lease, lock Swing while the human watches, and must ask permission to write the original feature. Take control always returns the floor to the human. The Step Editor exposes two distinct execution actions: diff --git a/pickleball-workbench/build.gradle b/pickleball-workbench/build.gradle index ba9f7eff..d251577d 100644 --- a/pickleball-workbench/build.gradle +++ b/pickleball-workbench/build.gradle @@ -574,7 +574,10 @@ tasks.register('verifyWorkbenchMcpStdio') { if (!names.containsAll([ 'workbench_worker_status', 'workbench_execute_step', - 'workbench_step_override_compile' + 'workbench_step_override_compile', + 'workbench_request_control', + 'workbench_player_state', + 'workbench_request_save' ])) { throw new GradleException("Workbench MCP tool list is incomplete: ${names}") } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java index 7a3a2d6b..16ef3d90 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java @@ -436,6 +436,7 @@ private static void printUsage(PrintStream out) { out.println("worker-check starts, restarts, and gracefully stops direct consumer workers without rebuilding."); out.println("live-check exercises raw Gherkin, Step Override, and live runtime operations on one persistent worker."); out.println("mcp serves the same Workbench services over protocol-only stdio; diagnostics use stderr/log files."); + out.println("ui opens the thin Swing Workbench over the same controller services and writes a localhost agent-attach endpoint to .pickleball/workbench/attach.json."); out.println("ui opens the thin Swing Workbench over the same controller services."); } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java index f2d7b6a8..717e4d9d 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -1,6 +1,19 @@ package tools.dscode.workbench; import tools.dscode.control.protocol.*; +import tools.dscode.workbench.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.lease.WorkbenchPermissionCancelledException; +import tools.dscode.workbench.lease.WorkbenchPermissionDecision; +import tools.dscode.workbench.lease.WorkbenchPermissionKind; +import tools.dscode.workbench.lease.WorkbenchPermissionRequest; +import tools.dscode.workbench.player.LiveFeatureSave; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.ScenarioOrigin; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; import tools.dscode.workbench.terminal.WorkerLogFiles; @@ -10,21 +23,191 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.Supplier; /** Plain-Java controller shared by Workbench adapters. */ public final class WorkbenchController implements WorkbenchServices { private final Path projectRoot; private final WorkbenchSynchronizer synchronizer; private final WorkbenchLiveSession live; + private final LiveScenarioPlayer player; + private final LivePlaybackCoordinator playback; + private final WorkbenchControlLease lease; + private final List playerListeners = new CopyOnWriteArrayList<>(); public WorkbenchController(Path projectRoot) { this.projectRoot = projectRoot.toAbsolutePath().normalize(); this.synchronizer = new WorkbenchSynchronizer(); this.live = new WorkbenchLiveSession(this.projectRoot); + this.player = LiveScenarioPlayer.interactiveBuffer(); + this.playback = new LivePlaybackCoordinator(this.player); + this.lease = new WorkbenchControlLease(); + } + + @Override + public LiveScenarioPlayer player() { + return player; + } + + @Override + public LivePlaybackCoordinator playback() { + return playback; + } + + @Override + public WorkbenchPlayerState playerState() { + ScenarioOrigin origin = playback.origin(); + LiveScenarioPlayer.Line playhead = player.playheadLine().orElse(null); + return new WorkbenchPlayerState( + player.documentText(), + player.lines().stream().map(LiveScenarioPlayer.Line::text).toList(), + player.state(), + player.playheadId().isPresent() ? player.playheadId().getAsLong() : null, + playhead == null ? "" : playhead.text(), + player.selectedId().isPresent() ? player.selectedId().getAsLong() : null, + origin.file() == null ? "" : origin.file().toString(), + origin.scenarioName(), + origin.savable() + ); + } + + @Override + public WorkbenchControlLease controlLease() { + return lease; + } + + @Override + public WorkbenchControlLeaseSnapshot controlLeaseSnapshot() { + return lease.snapshot(); + } + + @Override + public WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName) { + return lease.requestControl(agentDisplayName); + } + + @Override + public WorkbenchControlLeaseSnapshot releaseControl() { + return lease.releaseControl(); + } + + @Override + public WorkbenchControlLeaseSnapshot takeControl() { + return lease.takeControl(); + } + + @Override + public WorkbenchControlLeaseSnapshot setCurrentAction(String text) { + return lease.setCurrentAction(text); + } + + @Override + public void answerPermission(String requestId, boolean allow) { + lease.answerPermission(requestId, allow); + } + + @Override + public void attachUi() { + lease.attachUi(); + } + + @Override + public void detachUi() { + lease.detachUi(); + } + + @Override + public void addLeaseListener(Consumer listener) { + lease.addListener(listener); + } + + @Override + public void removeLeaseListener(Consumer listener) { + lease.removeListener(listener); + } + + @Override + public void addPlayerListener(Runnable listener) { + playerListeners.add(listener); + } + + @Override + public void removePlayerListener(Runnable listener) { + playerListeners.remove(listener); + } + + @Override + public void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + requireMutating(); + playback.loadScenario(lines, originFile, scenarioName, startLine, endLine); + notifyPlayer(); + } + + @Override + public void loadDefaultDemo() { + requireMutating(); + playback.loadDefaultDemo(); + notifyPlayer(); + } + + @Override + public void replaceLiveDocument(List lines) { + requireMutating(); + playback.replaceFromLines(lines); + notifyPlayer(); + } + + @Override + public WorkbenchSavePreview savePreview() { + return LiveFeatureSave.preview(playback); + } + + @Override + public WorkbenchSaveResult requestSave() { + requireMutating(); + WorkbenchSavePreview preview = LiveFeatureSave.preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + WorkbenchPermissionRequest request = new WorkbenchPermissionRequest( + WorkbenchControlLease.newPermissionId(), + WorkbenchPermissionKind.SAVE, + preview.summary(), + preview.featurePath() == null ? "" : preview.featurePath().toString(), + preview.scenarioName() + ); + try { + WorkbenchPermissionDecision decision = lease.awaitPermission(request); + if (decision != WorkbenchPermissionDecision.ALLOW) { + return WorkbenchSaveResult.denied(); + } + return LiveFeatureSave.write(playback); + } catch (WorkbenchPermissionCancelledException cancelled) { + return WorkbenchSaveResult.cancelled(cancelled.getMessage()); + } + } + + @Override + public WorkbenchSaveResult commitSave() { + requireMutating(); + WorkbenchSavePreview preview = LiveFeatureSave.preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + return LiveFeatureSave.write(playback); } @Override public WorkbenchManifest synchronize() { + requireMutating(); if (live.status().running()) { throw new IllegalStateException("Stop the Workbench worker before synchronizing the project."); } @@ -38,17 +221,17 @@ public WorkbenchManifest synchronizationStatus() { @Override public WorkbenchWorkerStatus startWorker() { - return live.start(); + return mutating(live::start); } @Override public WorkbenchWorkerStatus restartWorker() { - return live.restart(); + return mutating(live::restart); } @Override public WorkbenchWorkerStatus stopWorker() { - return live.stop(); + return mutating(live::stop); } @Override @@ -68,7 +251,10 @@ public Optional workerLogFiles() { @Override public ControlBridgeCallResult executeStep(String text, String argument) { - return live.executeStep(text, argument == null ? "" : argument); + requireMutating(); + ControlBridgeCallResult result = live.executeStep(text, argument == null ? "" : argument); + maybeAdvancePlayhead(text, "SUCCESS".equals(result.status())); + return result; } @Override @@ -78,7 +264,7 @@ public ControlBridgeValueResult mappingGet(String mapReference, String key) { @Override public ControlBridgeValueResult mappingPut(String mapReference, String key, Object value) { - return live.mappingPut(mapReference, key, value); + return mutating(() -> live.mappingPut(mapReference, key, value)); } @Override @@ -93,7 +279,7 @@ public ControlBridgeMappingSnapshotResult mappingSnapshot(String mapReference) { @Override public ControlBridgeCallResult mappingRestore(ControlBridgeMappingSnapshot snapshot) { - return live.mappingRestore(snapshot); + return mutating(() -> live.mappingRestore(snapshot)); } @Override @@ -120,7 +306,7 @@ public ControlBridgeElementInspectionResult elementInspect( @Override public ControlBridgeServiceCallResult serviceCall(String selector) { - return live.serviceCall(selector); + return mutating(() -> live.serviceCall(selector)); } @Override @@ -137,19 +323,19 @@ public ControlBridgeBreakpoint addBreakpoint( boolean oneShot, Integer leaseSeconds ) { - return live.addBreakpoint( + return mutating(() -> live.addBreakpoint( hook, signatureContains, stepContains, phraseContains, oneShot, leaseSeconds - ); + )); } @Override public boolean removeBreakpoint(String breakpointId) { - return live.removeBreakpoint(breakpointId); + return mutating(() -> live.removeBreakpoint(breakpointId)); } @Override public int clearBreakpoints() { - return live.clearBreakpoints(); + return mutating(live::clearBreakpoints); } @Override @@ -159,21 +345,58 @@ public List stepOverrides() { @Override public ControlBridgeStepOverrideResult compileStepOverride(String id, String regex, String source) { - return live.compileStepOverride(id, regex, source); + return mutating(() -> live.compileStepOverride(id, regex, source)); } @Override public boolean removeStepOverride(String id) { - return live.removeStepOverride(id); + return mutating(() -> live.removeStepOverride(id)); } @Override public int clearStepOverrides() { - return live.clearStepOverrides(); + return mutating(live::clearStepOverrides); } @Override public void close() { + lease.detachUi(); live.close(); } + + private void maybeAdvancePlayhead(String text, boolean successful) { + LiveScenarioPlayer.Line next = player.nextStep().orElse(null); + if (next != null && text != null && next.text().equals(text) + && player.state() == LiveScenarioPlayer.State.RUNNING) { + try { + if (successful) { + player.markCurrentStepExecuted(next.id()); + } else { + player.markCurrentStepFailed(next.id()); + } + } catch (RuntimeException ignored) { + // Playhead follow is best-effort presentation; worker execution already finished. + } + } + notifyPlayer(); + } + + private T mutating(Supplier action) { + requireMutating(); + return action.get(); + } + + private void requireMutating() { + lease.requireMutatingAccess(); + } + + private void notifyPlayer() { + for (Runnable listener : playerListeners) { + try { + listener.run(); + } catch (RuntimeException ignored) { + // Presentation listeners must not break live execution. + } + } + } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java index fabd19cb..a7b5ce0f 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -12,6 +12,13 @@ import tools.dscode.control.protocol.ControlBridgeStepOverride; import tools.dscode.control.protocol.ControlBridgeStepOverrideResult; import tools.dscode.control.protocol.ControlBridgeValueResult; +import tools.dscode.workbench.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -19,9 +26,60 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; /** Shared Workbench controller surface used by protocol and presentation adapters. */ public interface WorkbenchServices extends AutoCloseable { + LiveScenarioPlayer player(); + + LivePlaybackCoordinator playback(); + + WorkbenchPlayerState playerState(); + + WorkbenchControlLease controlLease(); + + WorkbenchControlLeaseSnapshot controlLeaseSnapshot(); + + WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName); + + WorkbenchControlLeaseSnapshot releaseControl(); + + WorkbenchControlLeaseSnapshot takeControl(); + + WorkbenchControlLeaseSnapshot setCurrentAction(String text); + + void answerPermission(String requestId, boolean allow); + + void attachUi(); + + void detachUi(); + + void addLeaseListener(Consumer listener); + + void removeLeaseListener(Consumer listener); + + void addPlayerListener(Runnable listener); + + void removePlayerListener(Runnable listener); + + void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ); + + void loadDefaultDemo(); + + void replaceLiveDocument(List lines); + + WorkbenchSavePreview savePreview(); + + WorkbenchSaveResult requestSave(); + + WorkbenchSaveResult commitSave(); + WorkbenchManifest synchronize(); WorkbenchManifest synchronizationStatus(); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java new file mode 100644 index 00000000..28b04753 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.lease; + +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Identifies whether the current thread is acting as the human UI adapter or as + * an attached AI agent. Adapters set this around {@code WorkbenchServices} calls + * so lease checks stay in Workbench rather than Swing or MCP. + */ +public final class WorkbenchCallContext { + private static final ThreadLocal HOLDER = + ThreadLocal.withInitial(() -> WorkbenchLeaseHolder.HUMAN); + + private WorkbenchCallContext() { + } + + public static WorkbenchLeaseHolder current() { + return HOLDER.get(); + } + + public static void runAs(WorkbenchLeaseHolder holder, Runnable action) { + callAs(holder, () -> { + action.run(); + return null; + }); + } + + public static T callAs(WorkbenchLeaseHolder holder, Supplier action) { + Objects.requireNonNull(holder, "holder"); + Objects.requireNonNull(action, "action"); + WorkbenchLeaseHolder previous = HOLDER.get(); + HOLDER.set(holder); + try { + return action.get(); + } finally { + HOLDER.set(previous); + } + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java new file mode 100644 index 00000000..7f9f3a01 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java @@ -0,0 +1,289 @@ +package tools.dscode.workbench.lease; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +/** + * Controller-owned live-control lease. Swing and MCP/HTTP adapters observe this + * state; they do not keep a second copy. + */ +public final class WorkbenchControlLease { + private static final long PERMISSION_WAIT_NS = TimeUnit.MINUTES.toNanos(30); + + private final ReentrantLock lock = new ReentrantLock(); + private final Condition permissionAnswered = lock.newCondition(); + private final List> listeners = new CopyOnWriteArrayList<>(); + + private WorkbenchLeaseHolder holder = WorkbenchLeaseHolder.HUMAN; + private String agentDisplayName = ""; + private String currentAction = ""; + private boolean uiAttached; + private WorkbenchPermissionRequest pendingPermission; + private WorkbenchPermissionDecision pendingDecision; + + public void addListener(Consumer listener) { + listeners.add(Objects.requireNonNull(listener, "listener")); + listener.accept(snapshot()); + } + + public void removeListener(Consumer listener) { + listeners.remove(listener); + } + + public void attachUi() { + lock.lock(); + try { + uiAttached = true; + if (holder == WorkbenchLeaseHolder.AGENT) { + // A visible UI always starts with the human holding the floor. + } else { + holder = WorkbenchLeaseHolder.HUMAN; + } + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public void detachUi() { + lock.lock(); + try { + uiAttached = false; + failPendingLocked("The Workbench UI closed before the permission request was answered."); + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public WorkbenchControlLeaseSnapshot snapshot() { + lock.lock(); + try { + return snapshotLocked(); + } finally { + lock.unlock(); + } + } + + public WorkbenchControlLeaseSnapshot requestControl(String agentDisplayName) { + String name = requiredName(agentDisplayName); + lock.lock(); + try { + if (holder == WorkbenchLeaseHolder.AGENT + && !this.agentDisplayName.isBlank() + && !this.agentDisplayName.equals(name)) { + throw new IllegalStateException( + "Another AI agent already holds the Workbench control lease (" + + this.agentDisplayName + ")." + ); + } + holder = WorkbenchLeaseHolder.AGENT; + this.agentDisplayName = name; + if (currentAction.isBlank()) { + currentAction = "Waiting to work in the live scenario."; + } + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot releaseControl() { + requireAgentCaller(); + lock.lock(); + try { + if (holder != WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException("The AI agent does not currently hold the Workbench control lease."); + } + failPendingLocked("The AI agent released control before the permission request was answered."); + holder = WorkbenchLeaseHolder.HUMAN; + agentDisplayName = ""; + currentAction = ""; + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot takeControl() { + lock.lock(); + try { + failPendingLocked("The human took control before the permission request was answered."); + holder = WorkbenchLeaseHolder.HUMAN; + agentDisplayName = ""; + currentAction = ""; + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public WorkbenchControlLeaseSnapshot setCurrentAction(String text) { + requireAgentCaller(); + lock.lock(); + try { + requireHolderLocked(WorkbenchLeaseHolder.AGENT); + currentAction = text == null ? "" : text.strip(); + return snapshotLocked(); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + /** + * Live testing and Mapping/evidence reads stay available to the lease holder. + * Call this before mutating worker, player-buffer, Mapping write, or Save paths. + */ + public void requireMutatingAccess() { + WorkbenchLeaseHolder caller = WorkbenchCallContext.current(); + lock.lock(); + try { + requireHolderLocked(caller); + } finally { + lock.unlock(); + } + } + + public boolean uiAttached() { + lock.lock(); + try { + return uiAttached; + } finally { + lock.unlock(); + } + } + + /** + * Blocks when a UI is attached until Allow, Deny, or Take control. Headless + * stdio has no banner, so the explicit tool call itself is the approval. + */ + public WorkbenchPermissionDecision awaitPermission(WorkbenchPermissionRequest request) { + Objects.requireNonNull(request, "request"); + requireAgentCaller(); + lock.lock(); + try { + requireHolderLocked(WorkbenchLeaseHolder.AGENT); + if (pendingPermission != null) { + throw new IllegalStateException("Another Workbench permission request is already pending."); + } + if (!uiAttached) { + return WorkbenchPermissionDecision.ALLOW; + } + pendingPermission = request; + pendingDecision = null; + } finally { + lock.unlock(); + } + notifyListeners(); + + lock.lock(); + try { + long remaining = PERMISSION_WAIT_NS; + while (pendingDecision == null && remaining > 0) { + remaining = permissionAnswered.awaitNanos(remaining); + } + WorkbenchPermissionDecision decision = pendingDecision; + pendingPermission = null; + pendingDecision = null; + if (decision == null) { + throw new WorkbenchPermissionCancelledException( + "Timed out waiting for a human Allow/Deny decision." + ); + } + if (decision == WorkbenchPermissionDecision.CANCELLED) { + throw new WorkbenchPermissionCancelledException(); + } + return decision; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + pendingPermission = null; + pendingDecision = null; + throw new WorkbenchPermissionCancelledException("Interrupted while waiting for Save permission."); + } finally { + lock.unlock(); + notifyListeners(); + } + } + + public void answerPermission(String requestId, boolean allow) { + lock.lock(); + try { + if (pendingPermission == null || !pendingPermission.id().equals(requestId)) { + throw new IllegalStateException("No matching Workbench permission request is pending."); + } + pendingDecision = allow ? WorkbenchPermissionDecision.ALLOW : WorkbenchPermissionDecision.DENY; + permissionAnswered.signalAll(); + } finally { + lock.unlock(); + } + notifyListeners(); + } + + public static String newPermissionId() { + return UUID.randomUUID().toString(); + } + + private void failPendingLocked(String message) { + if (pendingPermission == null) return; + pendingDecision = WorkbenchPermissionDecision.CANCELLED; + permissionAnswered.signalAll(); + // Keep pendingPermission until the waiter clears it so the UI can drop the banner. + pendingPermission = pendingPermission; + } + + private void requireHolderLocked(WorkbenchLeaseHolder expected) { + if (holder == expected) return; + if (expected == WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException( + "The AI agent does not hold the Workbench control lease. Call workbench_request_control first." + ); + } + throw new IllegalStateException( + "An AI agent currently holds the Workbench control lease. Take control before using these controls." + ); + } + + private static void requireAgentCaller() { + if (WorkbenchCallContext.current() != WorkbenchLeaseHolder.AGENT) { + throw new IllegalStateException("Only an attached AI agent can use this control-lease action."); + } + } + + private WorkbenchControlLeaseSnapshot snapshotLocked() { + return new WorkbenchControlLeaseSnapshot( + holder, + agentDisplayName, + currentAction, + uiAttached, + pendingPermission + ); + } + + private void notifyListeners() { + WorkbenchControlLeaseSnapshot snapshot = snapshot(); + for (Consumer listener : listeners) { + try { + listener.accept(snapshot); + } catch (RuntimeException ignored) { + // Presentation listeners must not break lease transitions. + } + } + } + + private static String requiredName(String agentDisplayName) { + if (agentDisplayName == null || agentDisplayName.isBlank()) { + throw new IllegalArgumentException("Agent display name must not be blank."); + } + return agentDisplayName.strip(); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java new file mode 100644 index 00000000..4193bafa --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java @@ -0,0 +1,40 @@ +package tools.dscode.workbench.lease; + +import java.util.Objects; +import java.util.Optional; + +/** Immutable view of the controller-owned control lease. */ +public record WorkbenchControlLeaseSnapshot( + WorkbenchLeaseHolder holder, + String agentDisplayName, + String currentAction, + boolean uiAttached, + WorkbenchPermissionRequest pendingPermission +) { + public WorkbenchControlLeaseSnapshot { + Objects.requireNonNull(holder, "holder"); + agentDisplayName = agentDisplayName == null ? "" : agentDisplayName; + currentAction = currentAction == null ? "" : currentAction; + } + + public boolean agentHolds() { + return holder == WorkbenchLeaseHolder.AGENT; + } + + public boolean humanHolds() { + return holder == WorkbenchLeaseHolder.HUMAN; + } + + public Optional pending() { + return Optional.ofNullable(pendingPermission); + } + + public String bannerText() { + if (!agentHolds()) return ""; + String name = agentDisplayName.isBlank() ? "AI agent" : agentDisplayName; + if (currentAction.isBlank()) { + return name + " is in control of Workbench."; + } + return name + " is in control — " + currentAction; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java new file mode 100644 index 00000000..1ae4f3cd --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java @@ -0,0 +1,7 @@ +package tools.dscode.workbench.lease; + +/** Who currently has the Workbench live-control floor. */ +public enum WorkbenchLeaseHolder { + HUMAN, + AGENT +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java new file mode 100644 index 00000000..0dc97b8a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java @@ -0,0 +1,12 @@ +package tools.dscode.workbench.lease; + +/** Raised when Take control (or release) aborts an in-flight agent permission wait. */ +public final class WorkbenchPermissionCancelledException extends IllegalStateException { + public WorkbenchPermissionCancelledException() { + super("The human took control before the permission request was answered."); + } + + public WorkbenchPermissionCancelledException(String message) { + super(message); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java new file mode 100644 index 00000000..8c8f4149 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java @@ -0,0 +1,8 @@ +package tools.dscode.workbench.lease; + +/** Human answer to a pending agent permission request. */ +public enum WorkbenchPermissionDecision { + ALLOW, + DENY, + CANCELLED +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java new file mode 100644 index 00000000..3405e24a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java @@ -0,0 +1,6 @@ +package tools.dscode.workbench.lease; + +/** Gated actions that wait for an explicit Allow/Deny when a UI is attached. */ +public enum WorkbenchPermissionKind { + SAVE +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java new file mode 100644 index 00000000..bfcdfe4a --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java @@ -0,0 +1,25 @@ +package tools.dscode.workbench.lease; + +import java.nio.file.Path; +import java.util.Objects; + +/** One pending Allow/Deny request shown in the Workbench UI. */ +public record WorkbenchPermissionRequest( + String id, + WorkbenchPermissionKind kind, + String summary, + String featurePath, + String scenarioName +) { + public WorkbenchPermissionRequest { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(kind, "kind"); + summary = summary == null ? "" : summary; + featurePath = featurePath == null ? "" : featurePath; + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public Path originFile() { + return featurePath.isBlank() ? null : Path.of(featurePath); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java new file mode 100644 index 00000000..b28ca6d1 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java @@ -0,0 +1,231 @@ +package tools.dscode.workbench.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import tools.dscode.workbench.WorkbenchServices; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Localhost-only JSON facade over the same {@link WorkbenchMcpTools} used by + * stdio MCP. UI mode cannot share process stdout with stdio MCP, so an agent + * attaches to the visible Workbench through this endpoint. + */ +public final class WorkbenchAttachServer implements AutoCloseable { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final WorkbenchServices services; + private final Path projectRoot; + private final Path stateFile; + private final String token; + private final HttpServer http; + private final WorkbenchMcpTools tools; + private final ExecutorService executor; + private final AtomicBoolean closed = new AtomicBoolean(); + + private WorkbenchAttachServer( + WorkbenchServices services, + Path projectRoot, + Path stateFile, + String token, + HttpServer http, + WorkbenchMcpTools tools, + ExecutorService executor + ) { + this.services = services; + this.projectRoot = projectRoot; + this.stateFile = stateFile; + this.token = token; + this.http = http; + this.tools = tools; + this.executor = executor; + } + + public static WorkbenchAttachServer start(WorkbenchServices services, Path projectRoot) { + Objects.requireNonNull(services, "services"); + Path root = projectRoot.toAbsolutePath().normalize(); + try { + HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + String token = newToken(); + WorkbenchMcpTools tools = new WorkbenchMcpTools(services, JSON); + Path stateFile = attachStateFile(root); + ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "pickleball-workbench-attach"); + thread.setDaemon(true); + return thread; + }); + WorkbenchAttachServer server = new WorkbenchAttachServer( + services, root, stateFile, token, http, tools, executor + ); + http.createContext("/health", server::health); + http.createContext("/lease", server::lease); + http.createContext("/player", server::player); + http.createContext("/tools", server::tools); + http.setExecutor(executor); + http.start(); + server.writeStateFile(); + return server; + } catch (IOException failure) { + throw new IllegalStateException("Could not start the Workbench agent-attach endpoint.", failure); + } + } + + public static Path attachStateFile(Path projectRoot) { + return projectRoot.resolve(".pickleball").resolve("workbench").resolve("attach.json"); + } + + public String url() { + return "http://127.0.0.1:" + http.getAddress().getPort(); + } + + public String token() { + return token; + } + + public Path stateFile() { + return stateFile; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + http.stop(0); + executor.shutdownNow(); + try { + Files.deleteIfExists(stateFile); + } catch (IOException ignored) { + // Disposable attach state. + } + } + + private void health(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, Map.of( + "status", "ok", + "url", url(), + "pid", ProcessHandle.current().pid() + )); + } + + private void lease(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, services.controlLeaseSnapshot()); + } + + private void player(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + send(exchange, 200, services.playerState()); + } + + private void tools(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) return; + String path = exchange.getRequestURI().getPath(); + if ("/tools".equals(path) && "GET".equals(exchange.getRequestMethod())) { + send(exchange, 200, Map.of("tools", tools.names())); + return; + } + if (!path.startsWith("/tools/") || path.length() <= "/tools/".length()) { + send(exchange, 404, Map.of("error", "Unknown attach path")); + return; + } + if (!"POST".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "POST a JSON argument object to invoke a tool")); + return; + } + String name = path.substring("/tools/".length()); + Map arguments = readJsonObject(exchange.getRequestBody()); + try { + Object value = tools.call(name, arguments); + send(exchange, 200, value); + } catch (RuntimeException failure) { + send(exchange, 400, Map.of( + "error", failure.getClass().getSimpleName(), + "message", failure.getMessage() == null ? failure.toString() : failure.getMessage() + )); + } + } + + private boolean authorized(HttpExchange exchange) throws IOException { + String header = firstHeader(exchange.getRequestHeaders(), "Authorization"); + String tokenHeader = firstHeader(exchange.getRequestHeaders(), "X-Workbench-Token"); + String presented = tokenHeader; + if (header != null && header.regionMatches(true, 0, "Bearer ", 0, 7)) { + presented = header.substring(7).strip(); + } + if (token.equals(presented)) return true; + send(exchange, 401, Map.of("error", "Missing or invalid Workbench attach token")); + return false; + } + + private void writeStateFile() throws IOException { + Files.createDirectories(stateFile.getParent()); + Map payload = new LinkedHashMap<>(); + payload.put("url", url()); + payload.put("token", token); + payload.put("pid", ProcessHandle.current().pid()); + payload.put("project", projectRoot.toString()); + payload.put("mode", "ui-attach"); + payload.put("bind", "127.0.0.1"); + Files.writeString(stateFile, JSON.writerWithDefaultPrettyPrinter().writeValueAsString(payload)); + } + + private static String firstHeader(Headers headers, String name) { + if (headers == null) return null; + String value = headers.getFirst(name); + return value == null || value.isBlank() ? null : value.strip(); + } + + @SuppressWarnings("unchecked") + private static Map readJsonObject(InputStream input) throws IOException { + byte[] bytes = input.readAllBytes(); + if (bytes.length == 0) return Map.of(); + Object value = JSON.readValue(bytes, Object.class); + if (value instanceof Map map) { + return (Map) map; + } + throw new IllegalArgumentException("Tool arguments must be a JSON object."); + } + + private static void send(HttpExchange exchange, int status, Object body) throws IOException { + byte[] bytes = JSON.writeValueAsBytes(body); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static String newToken() { + byte[] bytes = new byte[24]; + RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index e928818e..f8515aee 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java @@ -5,6 +5,8 @@ import io.modelcontextprotocol.spec.McpSchema; import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -16,69 +18,124 @@ final class WorkbenchMcpTools { private final WorkbenchServices services; private final ObjectMapper json; + private final Map tools = new LinkedHashMap<>(); WorkbenchMcpTools(WorkbenchServices services, ObjectMapper json) { this.services = services; this.json = json; + register(); } List specifications() { - List tools = new ArrayList<>(); - - tools.add(tool("workbench_sync", "Synchronize the selected consumer project.", schema(Map.of()), - args -> services.synchronize())); - tools.add(tool("workbench_sync_status", "Read the current Workbench synchronization manifest.", schema(Map.of()), - args -> services.synchronizationStatus())); - tools.add(tool("workbench_worker_start", "Start the persistent interactive consumer worker.", schema(Map.of()), - args -> services.startWorker())); - tools.add(tool("workbench_worker_restart", "Restart the worker in a fresh JVM without rebuilding.", schema(Map.of()), - args -> services.restartWorker())); - tools.add(tool("workbench_worker_stop", "Stop the interactive worker cleanly.", schema(Map.of()), - args -> services.stopWorker())); - tools.add(tool("workbench_worker_status", "Read the current interactive worker status.", schema(Map.of()), - args -> services.workerStatus())); - - tools.add(tool("workbench_execute_step", "Execute raw Gherkin in the paused live scenario.", + List specifications = new ArrayList<>(); + for (ToolBinding binding : tools.values()) { + specifications.add(specification(binding)); + } + return List.copyOf(specifications); + } + + List names() { + return List.copyOf(tools.keySet()); + } + + Object call(String name, Map arguments) { + ToolBinding binding = tools.get(name); + if (binding == null) { + throw new IllegalArgumentException("Unknown Workbench tool: " + name); + } + return WorkbenchCallContext.callAs( + WorkbenchLeaseHolder.AGENT, + () -> binding.action.apply(arguments == null ? Map.of() : arguments) + ); + } + + private void register() { + add("workbench_sync", "Synchronize the selected consumer project.", schema(Map.of()), + args -> services.synchronize()); + add("workbench_sync_status", "Read the current Workbench synchronization manifest.", schema(Map.of()), + args -> services.synchronizationStatus()); + add("workbench_worker_start", "Start the persistent interactive consumer worker.", schema(Map.of()), + args -> services.startWorker()); + add("workbench_worker_restart", "Restart the worker in a fresh JVM without rebuilding.", schema(Map.of()), + args -> services.restartWorker()); + add("workbench_worker_stop", "Stop the interactive worker cleanly.", schema(Map.of()), + args -> services.stopWorker()); + add("workbench_worker_status", "Read the current interactive worker status.", schema(Map.of()), + args -> services.workerStatus()); + + add("workbench_request_control", + "Request the Workbench live-control lease so this agent can test the live scenario while the human watches.", + schema(Map.of("agentName", stringProperty("Display name shown in the Workbench banner.")), "agentName"), + args -> services.requestControl(text(args, "agentName"))); + add("workbench_release_control", + "Release the Workbench live-control lease back to the human.", + schema(Map.of()), + args -> services.releaseControl()); + add("workbench_set_current_action", + "Update the watched-agent banner with what this agent is currently doing.", + schema(Map.of("text", stringProperty("Short action text shown in the UI banner.")), "text"), + args -> services.setCurrentAction(text(args, "text"))); + add("workbench_control_lease", + "Read the current Workbench control-lease holder, banner action, and pending permission.", + schema(Map.of()), + args -> services.controlLeaseSnapshot()); + add("workbench_player_state", + "Read the shared live scenario buffer, playhead, player state, and originating feature path if any.", + schema(Map.of()), + args -> services.playerState()); + add("workbench_player_replace_document", + "Replace the live session buffer. Does not write the original .feature file.", + schema(Map.of("text", stringProperty("Full live Gherkin document.")), "text"), + args -> { + services.replaceLiveDocument(text(args, "text").lines().toList()); + return services.playerState(); + }); + add("workbench_request_save", + "Ask to copy the live scenario into the original .feature file. With a UI attached this waits for Allow/Deny and never writes on deny.", + schema(Map.of()), + args -> services.requestSave()); + + add("workbench_execute_step", "Execute raw Gherkin in the paused live scenario.", schema(Map.of( "text", stringProperty("Gherkin step text."), "argument", stringProperty("Optional DocString-style argument text.") ), "text"), - args -> services.executeStep(text(args, "text"), optionalText(args, "argument")))); + args -> services.executeStep(text(args, "text"), optionalText(args, "argument"))); - tools.add(tool("workbench_mapping_get", "Read one value from a Pickleball Mapping.", + add("workbench_mapping_get", "Read one value from a Pickleball Mapping.", schema(Map.of( "mapReference", stringProperty("Mapping reference."), "key", stringProperty("Mapping key.") ), "mapReference", "key"), - args -> services.mappingGet(text(args, "mapReference"), text(args, "key")))); - tools.add(tool("workbench_mapping_put", "Write one value into a Pickleball Mapping.", + args -> services.mappingGet(text(args, "mapReference"), text(args, "key"))); + add("workbench_mapping_put", "Write one value into a Pickleball Mapping.", schema(Map.of( "mapReference", stringProperty("Mapping reference."), "key", stringProperty("Mapping key."), "value", Map.of("description", "JSON-compatible value to store.") ), "mapReference", "key", "value"), - args -> services.mappingPut(text(args, "mapReference"), text(args, "key"), args.get("value")))); - tools.add(tool("workbench_mapping_resolve", "Resolve Pickleball mapping/template references in text.", + args -> services.mappingPut(text(args, "mapReference"), text(args, "key"), args.get("value"))); + add("workbench_mapping_resolve", "Resolve Pickleball mapping/template references in text.", schema(Map.of("input", stringProperty("Text to resolve.")), "input"), - args -> services.mappingResolve(text(args, "input")))); - tools.add(tool("workbench_mapping_snapshot", "Snapshot one Mapping for later restoration.", + args -> services.mappingResolve(text(args, "input"))); + add("workbench_mapping_snapshot", "Snapshot one Mapping for later restoration.", schema(Map.of("mapReference", stringProperty("Mapping reference.")), "mapReference"), - args -> services.mappingSnapshot(text(args, "mapReference")))); - tools.add(tool("workbench_mapping_restore", "Restore a previously returned Mapping snapshot.", + args -> services.mappingSnapshot(text(args, "mapReference"))); + add("workbench_mapping_restore", "Restore a previously returned Mapping snapshot.", schema(Map.of("snapshot", objectProperty("Snapshot returned by workbench_mapping_snapshot.")), "snapshot"), - args -> services.mappingRestore(json.convertValue(args.get("snapshot"), ControlBridgeMappingSnapshot.class)))); + args -> services.mappingRestore(json.convertValue(args.get("snapshot"), ControlBridgeMappingSnapshot.class))); - tools.add(tool("workbench_events", "Read semantic runtime events for the active scenario.", + add("workbench_events", "Read semantic runtime events for the active scenario.", schema(Map.of( "afterSequence", integerProperty("Return events after this sequence number."), "limit", integerProperty("Maximum events to return.") )), - args -> services.events(longValue(args, "afterSequence"), integer(args, "limit")))); - tools.add(tool("workbench_browser_page", "Read current browser page evidence.", schema(Map.of()), - args -> services.browserPage())); - tools.add(tool("workbench_browser_screenshot", "Capture current browser screenshot evidence.", schema(Map.of()), - args -> services.browserScreenshot())); - tools.add(tool("workbench_element_inspect", "Inspect browser elements using Pickleball element vocabulary.", + args -> services.events(longValue(args, "afterSequence"), integer(args, "limit"))); + add("workbench_browser_page", "Read current browser page evidence.", schema(Map.of()), + args -> services.browserPage()); + add("workbench_browser_screenshot", "Capture current browser screenshot evidence.", schema(Map.of()), + args -> services.browserScreenshot()); + add("workbench_element_inspect", "Inspect browser elements using Pickleball element vocabulary.", schema(Map.of( "category", stringProperty("Optional Pickleball element category."), "text", stringProperty("Optional visible/context text."), @@ -90,14 +147,14 @@ List specifications() { optionalText(args, "text"), optionalText(args, "operation"), integer(args, "maxElements") - ))); - tools.add(tool("workbench_service_call", "Execute an existing Pickleball service-call selector and return evidence.", + )); + add("workbench_service_call", "Execute an existing Pickleball service-call selector and return evidence.", schema(Map.of("selector", stringProperty("Existing service-call selector, for example %health-full-url.")), "selector"), - args -> services.serviceCall(text(args, "selector")))); + args -> services.serviceCall(text(args, "selector"))); - tools.add(tool("workbench_breakpoint_list", "List semantic breakpoints.", schema(Map.of()), - args -> services.breakpoints())); - tools.add(tool("workbench_breakpoint_add", "Add a semantic runtime breakpoint.", + add("workbench_breakpoint_list", "List semantic breakpoints.", schema(Map.of()), + args -> services.breakpoints()); + add("workbench_breakpoint_add", "Add a semantic runtime breakpoint.", schema(Map.of( "hook", stringProperty("Control hook name, for example BEFORE_STEP."), "signatureContains", stringProperty("Optional signature substring filter."), @@ -113,16 +170,16 @@ List specifications() { optionalText(args, "phraseContains"), bool(args, "oneShot", false), integer(args, "leaseSeconds") - ))); - tools.add(tool("workbench_breakpoint_remove", "Remove one semantic breakpoint.", + )); + add("workbench_breakpoint_remove", "Remove one semantic breakpoint.", schema(Map.of("breakpointId", stringProperty("Breakpoint id.")), "breakpointId"), - args -> Map.of("removed", services.removeBreakpoint(text(args, "breakpointId"))))); - tools.add(tool("workbench_breakpoint_clear", "Clear all semantic breakpoints.", schema(Map.of()), - args -> Map.of("removed", services.clearBreakpoints()))); + args -> Map.of("removed", services.removeBreakpoint(text(args, "breakpointId")))); + add("workbench_breakpoint_clear", "Clear all semantic breakpoints.", schema(Map.of()), + args -> Map.of("removed", services.clearBreakpoints())); - tools.add(tool("workbench_step_override_list", "List Step Overrides in the active live scenario.", schema(Map.of()), - args -> services.stepOverrides())); - tools.add(tool("workbench_step_override_compile", "Compile and install a scenario-scoped REPLACE/REGEX Step Override in the worker.", + add("workbench_step_override_list", "List Step Overrides in the active live scenario.", schema(Map.of()), + args -> services.stepOverrides()); + add("workbench_step_override_compile", "Compile and install a scenario-scoped REPLACE/REGEX Step Override in the worker.", schema(Map.of( "id", stringProperty("Stable override id; recompiling the id replaces it."), "regex", stringProperty("Regular expression matched before ordinary Cucumber glue."), @@ -130,37 +187,36 @@ List specifications() { ), "id", "regex", "source"), args -> services.compileStepOverride( text(args, "id"), text(args, "regex"), text(args, "source") - ))); - tools.add(tool("workbench_step_override_remove", "Remove one Step Override from the active scenario.", + )); + add("workbench_step_override_remove", "Remove one Step Override from the active scenario.", schema(Map.of("id", stringProperty("Override id.")), "id"), - args -> Map.of("removed", services.removeStepOverride(text(args, "id"))))); - tools.add(tool("workbench_step_override_clear", "Clear all Step Overrides from the active scenario.", schema(Map.of()), - args -> Map.of("removed", services.clearStepOverrides()))); - - return List.copyOf(tools); + args -> Map.of("removed", services.removeStepOverride(text(args, "id")))); + add("workbench_step_override_clear", "Clear all Step Overrides from the active scenario.", schema(Map.of()), + args -> Map.of("removed", services.clearStepOverrides())); } - private McpServerFeatures.SyncToolSpecification tool( + private void add( String name, String description, Map inputSchema, Function, Object> action ) { - McpSchema.Tool tool = McpSchema.Tool.builder(name, inputSchema) - .description(description) + tools.put(name, new ToolBinding(name, description, inputSchema, action)); + } + + private McpServerFeatures.SyncToolSpecification specification(ToolBinding binding) { + McpSchema.Tool tool = McpSchema.Tool.builder(binding.name, binding.inputSchema) + .description(binding.description) .build(); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) - .callHandler((exchange, request) -> invoke(action, request.arguments())) + .callHandler((exchange, request) -> invoke(binding.name, request.arguments())) .build(); } - private McpSchema.CallToolResult invoke( - Function, Object> action, - Map arguments - ) { + private McpSchema.CallToolResult invoke(String name, Map arguments) { try { - Object value = action.apply(arguments == null ? Map.of() : arguments); + Object value = call(name, arguments); return result(value, false); } catch (RuntimeException failure) { return result(Map.of( @@ -234,4 +290,12 @@ private static boolean bool(Map args, String name, boolean defau Object value = args.get(name); return value instanceof Boolean bool ? bool : defaultValue; } + + private record ToolBinding( + String name, + String description, + Map inputSchema, + Function, Object> action + ) { + } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java new file mode 100644 index 00000000..b959e3c7 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java @@ -0,0 +1,119 @@ +package tools.dscode.workbench.player; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Copies the live session buffer into the originating scenario of the original + * {@code .feature} file. Demo buffers have no save path. Picker load never writes. + */ +public final class LiveFeatureSave { + private LiveFeatureSave() { + } + + public static WorkbenchSavePreview preview(LivePlaybackCoordinator playback) { + Objects.requireNonNull(playback, "playback"); + ScenarioOrigin origin = playback.origin(); + if (!origin.savable()) { + return WorkbenchSavePreview.unsavable( + "The default demo is session-only and has no original .feature file to write." + ); + } + List body = scenarioBody(playback.player().documentText()); + String fileName = origin.file().getFileName().toString(); + String scenario = origin.scenarioName().isBlank() ? "(unnamed scenario)" : origin.scenarioName(); + return new WorkbenchSavePreview( + true, + origin.file(), + origin.scenarioName(), + "Copy these live steps into file " + fileName + " / scenario " + scenario + "?", + body + ); + } + + public static WorkbenchSaveResult write(LivePlaybackCoordinator playback) { + WorkbenchSavePreview preview = preview(playback); + if (!preview.savable()) { + return WorkbenchSaveResult.unsavable(preview.summary()); + } + Path file = preview.featurePath(); + ScenarioOrigin origin = playback.origin(); + try { + List original = Files.exists(file) + ? Files.readAllLines(file, StandardCharsets.UTF_8) + : new ArrayList<>(); + List replacement = preview.liveScenarioLines(); + List rewritten = splice(original, origin.startLine(), origin.endLine(), replacement); + String newline = detectNewline(file); + Files.writeString(file, join(rewritten, newline), StandardCharsets.UTF_8); + int newEnd = origin.startLine() + replacement.size() - 1; + playback.updateOrigin(origin.withEndLine(Math.max(origin.startLine(), newEnd))); + return WorkbenchSaveResult.written(file, origin.scenarioName()); + } catch (IOException failure) { + throw new IllegalStateException("Could not write the originating feature file: " + file, failure); + } + } + + static List scenarioBody(String documentText) { + List lines = splitPreserve(documentText); + int start = 0; + for (int i = 0; i < lines.size(); i++) { + String trimmed = lines.get(i).strip(); + if (trimmed.startsWith("Scenario:") || trimmed.startsWith("Scenario Outline:")) { + start = i; + break; + } + } + List body = new ArrayList<>(lines.subList(start, lines.size())); + while (!body.isEmpty() && body.getLast().isBlank()) { + body.removeLast(); + } + return body; + } + + static List splice(List original, int startLine, int endLine, List replacement) { + List rewritten = new ArrayList<>(); + int start = Math.max(1, startLine); + int end = Math.max(start, endLine); + int size = original.size(); + for (int i = 1; i < start && i <= size; i++) { + rewritten.add(original.get(i - 1)); + } + rewritten.addAll(replacement); + for (int i = end + 1; i <= size; i++) { + rewritten.add(original.get(i - 1)); + } + return rewritten; + } + + private static List splitPreserve(String documentText) { + if (documentText == null || documentText.isEmpty()) return new ArrayList<>(); + String normalized = documentText.replace("\r\n", "\n").replace('\r', '\n'); + String[] parts = normalized.split("\n", -1); + List lines = new ArrayList<>(parts.length); + for (String part : parts) { + lines.add(part); + } + if (!lines.isEmpty() && lines.getLast().isEmpty()) { + lines.removeLast(); + } + return lines; + } + + private static String detectNewline(Path file) throws IOException { + if (!Files.exists(file)) return System.lineSeparator(); + String raw = Files.readString(file, StandardCharsets.UTF_8); + if (raw.contains("\r\n")) return "\r\n"; + if (raw.contains("\n")) return "\n"; + return System.lineSeparator(); + } + + private static String join(List lines, String newline) { + return String.join(newline, lines) + newline; + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java index e5c263cc..d6912749 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java @@ -11,7 +11,7 @@ */ public final class LivePlaybackCoordinator { private final LiveScenarioPlayer player; - private java.nio.file.Path originFile; + private ScenarioOrigin origin = ScenarioOrigin.none(); public LivePlaybackCoordinator(LiveScenarioPlayer player) { this.player = Objects.requireNonNull(player, "player"); @@ -21,21 +21,41 @@ public LiveScenarioPlayer player() { return player; } + public ScenarioOrigin origin() { + return origin; + } + public Optional originFile() { - return Optional.ofNullable(originFile); + return origin.originFile(); } public void clearOrigin() { - originFile = null; + origin = ScenarioOrigin.none(); + } + + public void updateOrigin(ScenarioOrigin origin) { + this.origin = origin == null ? ScenarioOrigin.none() : origin; } public void loadDefaultDemo() { - originFile = null; + origin = ScenarioOrigin.none(); player.loadDocument(LiveScenarioPlayer.DEFAULT_DEMO_SCENARIO); } - public void loadScenario(List lines, java.nio.file.Path origin) { - originFile = origin; + public void loadScenario(List lines, java.nio.file.Path originFile) { + loadScenario(lines, originFile, "", 0, 0); + } + + public void loadScenario( + List lines, + java.nio.file.Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + origin = originFile == null + ? ScenarioOrigin.none() + : new ScenarioOrigin(originFile, scenarioName, startLine, endLine); player.loadDocument(lines); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java new file mode 100644 index 00000000..569dad6d --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java @@ -0,0 +1,33 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +/** Origin of a picker-loaded live buffer. Demo sessions have no save path. */ +public record ScenarioOrigin( + Path file, + String scenarioName, + int startLine, + int endLine +) { + public ScenarioOrigin { + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public static ScenarioOrigin none() { + return new ScenarioOrigin(null, "", 0, 0); + } + + public boolean savable() { + return file != null; + } + + public Optional originFile() { + return Optional.ofNullable(file); + } + + public ScenarioOrigin withEndLine(int newEndLine) { + return new ScenarioOrigin(file, scenarioName, startLine, newEndLine); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java new file mode 100644 index 00000000..adb9df31 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java @@ -0,0 +1,33 @@ +package tools.dscode.workbench.player; + +import tools.dscode.workbench.player.LiveScenarioPlayer.State; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Read-only live-buffer snapshot for MCP/HTTP attach clients. */ +public record WorkbenchPlayerState( + String documentText, + List lines, + State playerState, + Long playheadId, + String playheadText, + Long selectedId, + String sourceFeaturePath, + String scenarioName, + boolean savable +) { + public WorkbenchPlayerState { + Objects.requireNonNull(playerState, "playerState"); + documentText = documentText == null ? "" : documentText; + lines = List.copyOf(lines == null ? List.of() : lines); + sourceFeaturePath = sourceFeaturePath == null ? "" : sourceFeaturePath; + scenarioName = scenarioName == null ? "" : scenarioName; + } + + public Optional originFile() { + return sourceFeaturePath.isBlank() ? Optional.empty() : Optional.of(Path.of(sourceFeaturePath)); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java new file mode 100644 index 00000000..2ba441ea --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java @@ -0,0 +1,29 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Preview of a gated Save into the originating scenario. */ +public record WorkbenchSavePreview( + boolean savable, + Path featurePath, + String scenarioName, + String summary, + List liveScenarioLines +) { + public WorkbenchSavePreview { + scenarioName = scenarioName == null ? "" : scenarioName; + summary = summary == null ? "" : summary; + liveScenarioLines = List.copyOf(liveScenarioLines == null ? List.of() : liveScenarioLines); + } + + public Optional originFile() { + return Optional.ofNullable(featurePath); + } + + public static WorkbenchSavePreview unsavable(String reason) { + return new WorkbenchSavePreview(false, null, "", reason, List.of()); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java new file mode 100644 index 00000000..6480f156 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java @@ -0,0 +1,42 @@ +package tools.dscode.workbench.player; + +import java.nio.file.Path; +import java.util.Objects; + +/** Outcome of an explicit live-buffer Save. Deny/cancel never writes. */ +public record WorkbenchSaveResult( + boolean written, + String status, + String featurePath, + String scenarioName, + String message +) { + public WorkbenchSaveResult { + Objects.requireNonNull(status, "status"); + featurePath = featurePath == null ? "" : featurePath; + scenarioName = scenarioName == null ? "" : scenarioName; + message = message == null ? "" : message; + } + + public static WorkbenchSaveResult written(Path featurePath, String scenarioName) { + return new WorkbenchSaveResult( + true, + "WRITTEN", + featurePath.toString(), + scenarioName, + "Copied the live scenario into " + featurePath.getFileName() + " / " + scenarioName + "." + ); + } + + public static WorkbenchSaveResult denied() { + return new WorkbenchSaveResult(false, "DENIED", "", "", "Save was denied. The original feature file was not changed."); + } + + public static WorkbenchSaveResult cancelled(String message) { + return new WorkbenchSaveResult(false, "CANCELLED", "", "", message); + } + + public static WorkbenchSaveResult unsavable(String message) { + return new WorkbenchSaveResult(false, "UNSAVABLE", "", "", message); + } +} diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java index d0f7aad3..20706e5b 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java @@ -43,6 +43,7 @@ final class FeaturePickerPanel extends JPanel { private Consumer onScenario; private Runnable onSave; private boolean syncing; + private boolean locked; FeaturePickerPanel() { super(new BorderLayout(8, 8)); @@ -79,7 +80,7 @@ final class FeaturePickerPanel extends JPanel { featureList.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent event) { - if (catalog == null) return; + if (catalog == null || locked) return; int index = featureList.locationToIndex(event.getPoint()); if (index < 0) return; ConsumerFeatureCatalog.FeatureEntry feature = featureModel.get(index); @@ -94,7 +95,7 @@ public void mousePressed(MouseEvent event) { scenarioList.addListSelectionListener(event -> { if (event.getValueIsAdjusting()) return; ConsumerFeatureCatalog.ScenarioEntry selected = scenarioList.getSelectedValue(); - if (selected != null && onScenario != null) onScenario.accept(selected); + if (selected != null && onScenario != null && !locked) onScenario.accept(selected); }); search.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent event) { filterChanged(); } @@ -103,7 +104,7 @@ public void mousePressed(MouseEvent event) { }); saveButton.setEnabled(false); saveButton.addActionListener(event -> { - if (onSave != null) onSave.run(); + if (onSave != null && !locked) onSave.run(); }); } @@ -146,8 +147,21 @@ void onSave(Runnable onSave) { this.onSave = onSave; } + private boolean saveEnabled; + void setSaveEnabled(boolean enabled) { - saveButton.setEnabled(enabled); + saveEnabled = enabled; + saveButton.setEnabled(enabled && !locked); + } + + void setLocked(boolean locked) { + this.locked = locked; + saveButton.setEnabled(saveEnabled && !locked); + featureList.setEnabled(!locked); + scenarioList.setEnabled(!locked); + search.setEnabled(!locked); + featureNameMode.setEnabled(!locked); + filePathMode.setEnabled(!locked); } private JPanel header() { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java index dd01a8fa..0b891742 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchFrame.java @@ -5,10 +5,15 @@ import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; import tools.dscode.workbench.diagnostics.DiagnosticEvidenceNavigator; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; +import tools.dscode.workbench.lease.WorkbenchPermissionRequest; import tools.dscode.workbench.mapping.MappingTreeModel; import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.mcp.WorkbenchAttachServer; import tools.dscode.workbench.player.LivePlaybackCoordinator; import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.ui.web.DiagnosticExplorerHost; import tools.dscode.workbench.ui.web.GherkinEditorHost; @@ -47,8 +52,9 @@ final class WorkbenchFrame extends JFrame { private static final int MAPPING_SAVE_DELAY_MS = 650; private final WorkbenchUiController controller; - private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); - private final LivePlaybackCoordinator playback = new LivePlaybackCoordinator(player); + private final WorkbenchAttachServer attach; + private final LiveScenarioPlayer player; + private final LivePlaybackCoordinator playback; private final ObjectMapper json = new ObjectMapper(); private final FeaturePickerPanel picker = new FeaturePickerPanel(); private final TerminalPanel terminal = new TerminalPanel(); @@ -82,6 +88,18 @@ final class WorkbenchFrame extends JFrame { private final JLabel readinessLabel = new JLabel("Loading status..."); private final JLabel playerStatusLabel = new JLabel("Stopped"); private final JLabel activityLabel = new JLabel("Ready"); + private final JPanel agentBanner = new JPanel(new BorderLayout(12, 0)); + private final JLabel agentBannerLabel = new JLabel(); + private final JButton takeControlButton = WorkbenchTheme.accentButton( + "Take control", + "Return live Workbench controls to the human and cancel in-flight agent permission waits" + ); + private final JPanel permissionBar = new JPanel(new BorderLayout(12, 0)); + private final JLabel permissionLabel = new JLabel(); + private final JButton allowButton = WorkbenchTheme.accentButton("Allow", "Allow the agent to copy the live scenario into the original feature file"); + private final JButton denyButton = WorkbenchTheme.flatButton("Deny", "Deny the write; the original feature file is left unchanged"); + private WorkbenchControlLeaseSnapshot lastLease; + private String pendingPermissionId; private final JMenuItem syncItem = new JMenuItem("Synchronize"); private final JMenuItem refreshItem = new JMenuItem("Refresh Status"); @@ -117,8 +135,15 @@ final class WorkbenchFrame extends JFrame { private boolean closing; WorkbenchFrame(WorkbenchUiController controller) { + this(controller, null); + } + + WorkbenchFrame(WorkbenchUiController controller, WorkbenchAttachServer attach) { super("Pickleball Workbench"); this.controller = controller; + this.attach = attach; + this.player = controller.player(); + this.playback = controller.playback(); mappingSaveTimer.setRepeats(false); WorkbenchTheme.install(); @@ -134,7 +159,7 @@ final class WorkbenchFrame extends JFrame { JPanel root = new JPanel(new BorderLayout(10, 10)); root.setBackground(WorkbenchTheme.BACKGROUND); root.setBorder(new EmptyBorder(10, 12, 10, 12)); - root.add(playerBar(), BorderLayout.NORTH); + root.add(topChrome(), BorderLayout.NORTH); JSplitPane editorAndRight = new JSplitPane( JSplitPane.HORIZONTAL_SPLIT, @@ -169,6 +194,14 @@ final class WorkbenchFrame extends JFrame { refreshFeatureCatalog(); terminal.start(); + configureAgentChrome(); + controller.addLeaseListener(snapshot -> SwingUtilities.invokeLater(() -> applyLease(snapshot))); + controller.addPlayerListener(() -> SwingUtilities.invokeLater(() -> { + syncScenarioView(); + updatePlayerView(null); + })); + applyLease(controller.controlLease()); + addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent event) { @@ -206,6 +239,57 @@ private JMenuBar menuBar() { return bar; } + private JComponent topChrome() { + JPanel north = new JPanel(); + north.setOpaque(false); + north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS)); + north.add(agentBanner); + north.add(Box.createVerticalStrut(6)); + north.add(permissionBar); + north.add(Box.createVerticalStrut(6)); + north.add(playerBar()); + return north; + } + + private void configureAgentChrome() { + agentBanner.setBackground(new Color(0xFE, 0xF3, 0xC7)); + agentBanner.setBorder(WorkbenchTheme.cardBorder()); + agentBannerLabel.setForeground(WorkbenchTheme.TEXT); + agentBannerLabel.setFont(agentBannerLabel.getFont().deriveFont(Font.BOLD, 13f)); + takeControlButton.addActionListener(event -> { + controller.takeControl(); + applyLease(controller.controlLease()); + updatePlayerView("You took control of Workbench."); + }); + agentBanner.add(agentBannerLabel, BorderLayout.CENTER); + agentBanner.add(takeControlButton, BorderLayout.EAST); + agentBanner.setVisible(false); + + permissionBar.setBackground(new Color(0xDB, 0xEA, 0xFE)); + permissionBar.setBorder(WorkbenchTheme.cardBorder()); + permissionLabel.setForeground(WorkbenchTheme.TEXT); + JPanel permissionButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0)); + permissionButtons.setOpaque(false); + allowButton.addActionListener(event -> answerPermission(true)); + denyButton.addActionListener(event -> answerPermission(false)); + permissionButtons.add(allowButton); + permissionButtons.add(denyButton); + permissionBar.add(permissionLabel, BorderLayout.CENTER); + permissionBar.add(permissionButtons, BorderLayout.EAST); + permissionBar.setVisible(false); + } + + private void answerPermission(boolean allow) { + if (pendingPermissionId == null) return; + String id = pendingPermissionId; + pendingPermissionId = null; + controller.answerPermission(id, allow); + applyLease(controller.controlLease()); + updatePlayerView(allow + ? "Allowed the agent Save request." + : "Denied the agent Save request. The original feature file was not changed."); + } + private JPanel playerBar() { JPanel bar = new JPanel(new BorderLayout(12, 0)); bar.setBackground(WorkbenchTheme.SURFACE); @@ -413,6 +497,7 @@ private void configureWebViews() { try { gherkinHost.onDocument(this::applyEditorLines); gherkinHost.onSeek(id -> { + if (humanControlsLocked()) return; player.clickLine(id); updateFromHereAvailability(); refreshPlayheadHighlight(); @@ -459,7 +544,13 @@ private void configureWebViews() { private void configurePicker() { picker.onScenarioSelected(scenario -> { - playback.loadScenario(scenario.lines(), scenario.file()); + controller.loadPickerScenario( + scenario.lines(), + scenario.file(), + scenario.name(), + scenario.startLine(), + scenario.endLine() + ); picker.setSaveEnabled(true); syncScenarioView(); updatePlayerView("Loaded " + scenario.displayLabel() + " into the live session buffer."); @@ -491,7 +582,7 @@ private void togglePicker() { } private void applyEditorLines(List lines) { - if (syncingScenarioDocument) return; + if (syncingScenarioDocument || humanControlsLocked()) return; player.replaceDocument(lines); updateFromHereAvailability(); if (player.state() == LiveScenarioPlayer.State.RUNNING) { @@ -505,7 +596,11 @@ private void applyEditorLines(List lines) { private void pushGherkinView() { if (gherkinView == null) return; - gherkinView.evalJsonCall("window.setEditorState", WorkbenchWebJson.editorState(player, executingStepId)); + gherkinView.evalJsonCall("window.setEditorState", WorkbenchWebJson.editorState( + player, + executingStepId, + humanControlsLocked() + )); } private void pushMappingView() { @@ -522,12 +617,14 @@ private void pushMappingView() { selected == null ? null : new WorkbenchWebJson.MapChoice( selected.reference(), selected.label(), selected.restorable()), mappingModel, - mappingStatus.getText() + mappingStatus.getText(), + humanControlsLocked() ) ); } private void applyMappingPropertyEdit(MappingEditorHost.PropertyEdit edit) { + if (humanControlsLocked()) return; if (lastState == null || !lastState.liveReady() || loadedMapping == null) return; try { if (edit.oldKey() == null || edit.oldKey().isBlank() || edit.oldKey().equals(edit.key())) { @@ -570,15 +667,31 @@ private void applyMappingPropertyEdit(MappingEditorHost.PropertyEdit edit) { } private void saveLoadedFeature() { - Path origin = playback.originFile().orElse(null); - if (origin == null) { - showFailure("Could not save", new IllegalStateException("The default demo is session-only.")); + if (humanControlsLocked()) return; + WorkbenchSavePreview preview = controller.savePreview(); + if (!preview.savable()) { + showFailure("Could not save", new IllegalStateException(preview.summary())); + return; + } + int choice = JOptionPane.showConfirmDialog( + this, + preview.summary() + "\n\nWorkbench will not write the original feature file unless you confirm.", + "Save live scenario", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.QUESTION_MESSAGE + ); + if (choice != JOptionPane.OK_OPTION) { + updatePlayerView("Save cancelled. The original feature file was not changed."); return; } try { - Files.writeString(origin, player.documentText() + System.lineSeparator()); - updatePlayerView("Saved live buffer to " + origin.getFileName() + "."); - } catch (Exception failure) { + WorkbenchSaveResult result = controller.commitSave(); + if (result.written()) { + updatePlayerView(result.message()); + } else { + showFailure("Could not save", new IllegalStateException(result.message())); + } + } catch (RuntimeException failure) { showFailure("Could not save feature file", failure); } } @@ -679,12 +792,14 @@ public void changedUpdate(DocumentEvent event) { private void wirePlayerActions() { playButton.addActionListener(event -> runScenarioFromBeginning()); pauseButton.addActionListener(event -> { + if (humanControlsLocked()) return; player.pause(); updatePlayerView(playbackBusy ? "Pause requested; the current step will finish first." : "Scenario playback paused."); }); playerStopButton.addActionListener(event -> { + if (humanControlsLocked()) return; player.stop(); pendingFreshRun = false; pendingFreshRunStepId = null; @@ -729,6 +844,7 @@ private void runScenarioFromSelectedStep() { } private void requestFreshRun(Long startStepId) { + if (humanControlsLocked()) return; pendingIsolatedStep = null; if (playbackBusy || playbackPreparing) { pendingFreshRun = true; @@ -868,6 +984,7 @@ private void schedulePlaybackStep() { } private void insertStep() { + if (humanControlsLocked()) return; try { LiveScenarioPlayer.Line inserted = player.insertStep(stepText.getText()); stepText.setText(""); @@ -892,6 +1009,7 @@ private void insertStep() { } private void updateSelectedStep() { + if (humanControlsLocked()) return; try { LiveScenarioPlayer.Line updated = player.updateSelectedStep(stepText.getText()); syncScenarioView(); @@ -903,6 +1021,7 @@ private void updateSelectedStep() { } private void executeStepOnly() { + if (humanControlsLocked()) return; String text = stepText.getText(); if (text == null || text.isBlank()) { showFailure("Could not execute step", @@ -965,6 +1084,7 @@ private boolean runPendingIsolatedStep() { } private void mappingChanged() { + if (humanControlsLocked()) return; if (loadingMapping || loadedMapping == null || !loadedMapping.restorable()) return; if (player.state() == LiveScenarioPlayer.State.RUNNING || player.state() == LiveScenarioPlayer.State.WAITING_FOR_STEP) { @@ -1113,7 +1233,7 @@ private void setMappingEditor(String text, boolean editable) { } private void scenarioDocumentChanged() { - if (syncingScenarioDocument) return; + if (syncingScenarioDocument || humanControlsLocked()) return; player.replaceDocument(List.of(scenarioEditor.getText().split("\n", -1))); seekPlayheadToCaret(); updateFromHereAvailability(); @@ -1128,7 +1248,7 @@ private void scenarioDocumentChanged() { } private void seekPlayheadToCaret() { - if (syncingScenarioDocument) return; + if (syncingScenarioDocument || humanControlsLocked()) return; int lineIndex = lineIndexAtCaret(); List lines = player.lines(); if (lineIndex < 0 || lineIndex >= lines.size()) { @@ -1164,7 +1284,8 @@ private void syncScenarioView() { private void updateFromHereAvailability() { fromHereButton.setEnabled( - player.selectedLine() + !humanControlsLocked() + && player.selectedLine() .or(player::playheadLine) .map(LiveScenarioPlayer.Line::executable) .orElse(false) @@ -1282,6 +1403,59 @@ private void applyState(WorkbenchUiController.State state) { setMappingEditor("", false); mappingStatus.setText("Start the live worker to inspect Mapping."); } + if (humanControlsLocked()) { + applyLease(lastLease); + } + } + + private boolean humanControlsLocked() { + return lastLease != null && lastLease.agentHolds(); + } + + private void applyLease(WorkbenchControlLeaseSnapshot snapshot) { + lastLease = snapshot; + boolean locked = snapshot != null && snapshot.agentHolds(); + agentBanner.setVisible(locked); + if (locked) { + agentBannerLabel.setText(snapshot.bannerText()); + } + WorkbenchPermissionRequest pending = snapshot == null ? null : snapshot.pendingPermission(); + permissionBar.setVisible(pending != null); + if (pending != null) { + pendingPermissionId = pending.id(); + permissionLabel.setText(pending.summary()); + } else { + pendingPermissionId = null; + } + + picker.setLocked(locked); + scenarioEditor.setEditable(!locked); + stepText.setEditable(!locked); + playButton.setEnabled(!locked); + pauseButton.setEnabled(!locked); + playerStopButton.setEnabled(!locked); + stepOnlyButton.setEnabled(!locked); + takeControlButton.setEnabled(locked); + if (locked) { + fromHereButton.setEnabled(false); + syncItem.setEnabled(false); + startItem.setEnabled(false); + restartItem.setEnabled(false); + stopItem.setEnabled(false); + nodeMapSelector.setEnabled(false); + mappingEditor.setEnabled(false); + } else if (lastState != null) { + syncItem.setEnabled(!lastState.workerRunning()); + startItem.setEnabled(lastState.synchronizedProject() && !lastState.workerRunning()); + restartItem.setEnabled(lastState.workerRunning()); + stopItem.setEnabled(lastState.workerRunning()); + if (lastState.liveReady()) { + nodeMapSelector.setEnabled(nodeMapSelector.getItemCount() > 0); + } + } + updateFromHereAvailability(); + pushGherkinView(); + pushMappingView(); } private void runTask( @@ -1543,6 +1717,7 @@ private void closeWorkbench() { activityLabel.setText("Closing Workbench..."); runTask( () -> { + if (attach != null) attach.close(); controller.close(); return Boolean.TRUE; }, diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java index 04aa5239..c4689e05 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUi.java @@ -1,6 +1,7 @@ package tools.dscode.workbench.ui; import tools.dscode.workbench.WorkbenchController; +import tools.dscode.workbench.mcp.WorkbenchAttachServer; import javax.swing.*; import java.lang.reflect.InvocationTargetException; @@ -14,11 +15,11 @@ private WorkbenchUi() { public static void launch(Path projectRoot) { Runnable launch = () -> { WorkbenchTheme.install(); - WorkbenchUiController controller = new WorkbenchUiController( - projectRoot, - new WorkbenchController(projectRoot) - ); - new WorkbenchFrame(controller).setVisible(true); + WorkbenchController services = new WorkbenchController(projectRoot); + services.attachUi(); + WorkbenchAttachServer attach = WorkbenchAttachServer.start(services, projectRoot); + WorkbenchUiController controller = new WorkbenchUiController(projectRoot, services); + new WorkbenchFrame(controller, attach).setVisible(true); }; if (SwingUtilities.isEventDispatchThread()) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java index 818e15dc..a48c30ff 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchUiController.java @@ -21,7 +21,12 @@ import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; import tools.dscode.workbench.mapping.MappingValueCodec; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.terminal.WorkerLogFiles; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -250,6 +255,52 @@ Path projectRoot() { return projectRoot; } + LiveScenarioPlayer player() { + return services.player(); + } + + LivePlaybackCoordinator playback() { + return services.playback(); + } + + WorkbenchControlLeaseSnapshot controlLease() { + return services.controlLeaseSnapshot(); + } + + WorkbenchControlLeaseSnapshot takeControl() { + return services.takeControl(); + } + + void answerPermission(String requestId, boolean allow) { + services.answerPermission(requestId, allow); + } + + WorkbenchSavePreview savePreview() { + return services.savePreview(); + } + + WorkbenchSaveResult commitSave() { + return services.commitSave(); + } + + void loadPickerScenario( + java.util.List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine + ) { + services.loadPickerScenario(lines, originFile, scenarioName, startLine, endLine); + } + + void addLeaseListener(java.util.function.Consumer listener) { + services.addLeaseListener(listener); + } + + void addPlayerListener(Runnable listener) { + services.addPlayerListener(listener); + } + Optional workerLogFiles() { return services.workerLogFiles(); } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java index 342d4efa..325c647a 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/WorkbenchWebJson.java @@ -19,11 +19,16 @@ private WorkbenchWebJson() { } public static String editorState(LiveScenarioPlayer player, Long executingId) { + return editorState(player, executingId, false); + } + + public static String editorState(LiveScenarioPlayer player, Long executingId, boolean locked) { Map payload = new LinkedHashMap<>(); payload.put("roots", blocks(GherkinBlockDocument.fromPlayer(player).roots())); payload.put("selectedId", player.selectedId().isPresent() ? player.selectedId().getAsLong() : null); payload.put("playheadId", player.playheadId().isPresent() ? player.playheadId().getAsLong() : null); payload.put("executingId", executingId); + payload.put("locked", locked); return write(payload); } @@ -32,6 +37,16 @@ public static String mappingState( MapChoice selected, MappingTreeModel model, String status + ) { + return mappingState(entries, selected, model, status, false); + } + + public static String mappingState( + List entries, + MapChoice selected, + MappingTreeModel model, + String status, + boolean locked ) { Map payload = new LinkedHashMap<>(); List> maps = new ArrayList<>(); @@ -57,6 +72,7 @@ public static String mappingState( } } payload.put("properties", properties); + payload.put("locked", locked); return write(payload); } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css index 099ad28c..54f009fc 100644 --- a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.css @@ -90,3 +90,7 @@ button { border-left: 2px solid #e5e7eb; } .ghost { opacity: 0.45; } +body.locked .gherkin { caret-color: transparent; } +body.locked .handle { cursor: default; } +body.locked button { opacity: 0.5; pointer-events: none; } +body.locked { cursor: default; } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js index c5aace2d..76fcbed5 100644 --- a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/gherkin-editor.js @@ -1,6 +1,6 @@ (function () { var board = document.getElementById("board"); - var state = { roots: [], selectedId: null, playheadId: null, executingId: null, suppress: false }; + var state = { roots: [], selectedId: null, playheadId: null, executingId: null, suppress: false, locked: false }; var dragging = null; function classify(text) { @@ -31,7 +31,7 @@ card.dataset.id = String(block.id); if (block.id === state.playheadId) card.classList.add("playhead"); if (block.id === state.executingId) card.classList.add("executing"); - card.draggable = true; + card.draggable = !state.locked; var row = document.createElement("div"); row.className = "row"; @@ -42,6 +42,7 @@ input.className = "gherkin"; input.value = block.text; input.spellcheck = false; + input.readOnly = !!state.locked; input.addEventListener("input", function () { block.text = input.value; autosize(input); @@ -69,6 +70,10 @@ }); card.addEventListener("dragstart", function (event) { + if (state.locked) { + event.preventDefault(); + return; + } dragging = { id: block.id }; card.classList.add("ghost"); event.dataTransfer.setData("text/plain", String(block.id)); @@ -190,7 +195,7 @@ } function report() { - if (state.suppress || !window.gherkinHost || !window.gherkinHost.documentChanged) return; + if (state.locked || state.suppress || !window.gherkinHost || !window.gherkinHost.documentChanged) return; var lines = []; flatten(state.roots, 0, lines); window.gherkinHost.documentChanged(JSON.stringify({ @@ -201,7 +206,7 @@ } function reportSeek() { - if (state.suppress || !window.gherkinHost || !window.gherkinHost.seek) return; + if (state.locked || state.suppress || !window.gherkinHost || !window.gherkinHost.seek) return; window.gherkinHost.seek(state.selectedId == null ? -1 : state.selectedId); } @@ -212,11 +217,16 @@ state.selectedId = payload.selectedId; state.playheadId = payload.playheadId; state.executingId = payload.executingId; + state.locked = !!payload.locked; + document.body.classList.toggle("locked", state.locked); + var add = document.getElementById("add-block"); + if (add) add.disabled = state.locked; render(); state.suppress = false; }; document.getElementById("add-block").addEventListener("click", function () { + if (state.locked) return; if (window.gherkinHost && window.gherkinHost.requestAddStep) { window.gherkinHost.requestAddStep(); } diff --git a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js index 3bee9d02..92ddfdf8 100644 --- a/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js +++ b/pickleball-workbench/src/main/resources/tools/dscode/workbench/ui/web/mapping-editor.js @@ -34,7 +34,7 @@ wrap.className = "row"; var key = document.createElement("input"); key.value = property.key; - key.disabled = !model.restorable; + key.disabled = !model.restorable || !!model.locked; var type = document.createElement("select"); ["string", "numeric", "boolean", "object-as-json", "object-as-xml"].forEach(function (name) { var option = document.createElement("option"); @@ -43,10 +43,10 @@ if (name === property.type) option.selected = true; type.appendChild(option); }); - type.disabled = !model.restorable; + type.disabled = !model.restorable || !!model.locked; var value = document.createElement("textarea"); value.value = property.text; - value.disabled = !model.restorable; + value.disabled = !model.restorable || !!model.locked; function commit() { if (!window.mappingHost || !window.mappingHost.propertyChanged) return; window.mappingHost.propertyChanged(JSON.stringify({ @@ -70,12 +70,13 @@ } maps.addEventListener("change", function () { + if (model.locked) return; if (window.mappingHost && window.mappingHost.selectMap) { window.mappingHost.selectMap(maps.value); } }); document.getElementById("add").addEventListener("click", function () { - if (!model.restorable) return; + if (!model.restorable || model.locked) return; var key = "newProperty"; var n = 1; while (model.properties.some(function (item) { return item.key === key; })) { @@ -97,6 +98,11 @@ window.setMappingState = function (json) { model = typeof json === "string" ? JSON.parse(json) : json; status.textContent = model.status || ""; + document.body.classList.toggle("locked", !!model.locked); + var add = document.getElementById("add"); + if (add) add.disabled = !!model.locked || !model.restorable; + var mapsEl = document.getElementById("maps"); + if (mapsEl) mapsEl.disabled = !!model.locked; render(); }; diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java new file mode 100644 index 00000000..0d60e6a8 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java @@ -0,0 +1,168 @@ +package tools.dscode.workbench; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; +import tools.dscode.workbench.player.WorkbenchSaveResult; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchControllerLeaseTest { + @TempDir + Path project; + + @Test + void agentMutatingCallsFailUntilTheyHoldTheLease() { + try (WorkbenchController controller = new WorkbenchController(project)) { + IllegalStateException denied = assertThrows( + IllegalStateException.class, + () -> WorkbenchCallContext.runAs( + WorkbenchLeaseHolder.AGENT, + () -> controller.replaceLiveDocument(List.of("Given stay")) + ) + ); + assertTrue(denied.getMessage().contains("workbench_request_control")); + + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + controller.setCurrentAction("Editing the live buffer."); + controller.replaceLiveDocument(List.of("Scenario: Live", " Given stay")); + }); + assertTrue(controller.playerState().documentText().contains("Given stay")); + assertEquals("Copilot", controller.controlLeaseSnapshot().agentDisplayName()); + } + } + + @Test + void takeControlUnlocksHumanAndCancelsAgentSaveWaitWithoutWriting() throws Exception { + Path feature = project.resolve("src/test/resources/features/keep.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Keep + Scenario: Original + Given original + """); + String original = Files.readString(feature); + + try (WorkbenchController controller = new WorkbenchController(project)) { + controller.attachUi(); + controller.loadPickerScenario( + List.of("Feature: Keep", "", "Scenario: Original", " Given original", " And extra"), + feature, + "Original", + 2, + 3 + ); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + Thread saver = new Thread(() -> { + result.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return controller.requestSave(); + })); + }, "controller-save-wait"); + saver.setDaemon(true); + saver.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + + controller.takeControl(); + saver.join(2_000); + assertFalse(result.get().written()); + assertEquals("CANCELLED", result.get().status()); + assertEquals(original, Files.readString(feature)); + assertTrue(controller.controlLeaseSnapshot().humanHolds()); + } + } + + @Test + void deniedSaveDoesNotWriteAndApprovedSaveCopiesTheLiveScenario() throws Exception { + Path feature = project.resolve("src/test/resources/features/login.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Sign in + Scenario: Valid password + Given a user + """); + + try (WorkbenchController controller = new WorkbenchController(project)) { + controller.attachUi(); + controller.loadPickerScenario( + List.of("Feature: Sign in", "", "Scenario: Valid password", " Given a user", " And extra"), + feature, + "Valid password", + 2, + 3 + ); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference denied = new AtomicReference<>(); + Thread saver = new Thread(() -> { + denied.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return controller.requestSave(); + })); + }, "controller-save-deny"); + saver.setDaemon(true); + saver.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + String requestId = controller.controlLeaseSnapshot().pendingPermission().id(); + controller.answerPermission(requestId, false); + saver.join(2_000); + assertEquals("DENIED", denied.get().status()); + assertFalse(Files.readString(feature).contains("And extra")); + + CountDownLatch waitingAllow = new CountDownLatch(1); + AtomicReference allowed = new AtomicReference<>(); + Thread allowSaver = new Thread(() -> { + allowed.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waitingAllow.countDown(); + return controller.requestSave(); + })); + }, "controller-save-allow"); + allowSaver.setDaemon(true); + allowSaver.start(); + assertTrue(waitingAllow.await(2, TimeUnit.SECONDS)); + waitUntil(() -> controller.controlLeaseSnapshot().pending().isPresent(), 2_000); + controller.answerPermission(controller.controlLeaseSnapshot().pendingPermission().id(), true); + allowSaver.join(2_000); + assertTrue(allowed.get().written()); + assertTrue(Files.readString(feature).contains("And extra")); + } + } + + @Test + void demoCommitSaveStaysUnsavable() { + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchSaveResult result = controller.commitSave(); + assertFalse(result.written()); + assertEquals("UNSAVABLE", result.status()); + } + } + + private static void waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("Timed out waiting for controller lease condition."); + } + Thread.sleep(10); + } + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java new file mode 100644 index 00000000..6a814a44 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java @@ -0,0 +1,138 @@ +package tools.dscode.workbench.lease; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchControlLeaseTest { + + @Test + void humanHoldsByDefaultAndAgentMutationsFailUntilRequestControl() { + WorkbenchControlLease lease = new WorkbenchControlLease(); + assertTrue(lease.snapshot().humanHolds()); + + IllegalStateException denied = assertThrows( + IllegalStateException.class, + () -> WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, lease::requireMutatingAccess) + ); + assertTrue(denied.getMessage().contains("workbench_request_control")); + + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> lease.requestControl("Copilot")); + assertTrue(lease.snapshot().agentHolds()); + assertEquals("Copilot", lease.snapshot().agentDisplayName()); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, lease::requireMutatingAccess); + + IllegalStateException humanLocked = assertThrows(IllegalStateException.class, lease::requireMutatingAccess); + assertTrue(humanLocked.getMessage().contains("Take control")); + } + + @Test + void takeControlReturnsHumanUnlocksAndFailsInFlightPermissionWaits() throws Exception { + WorkbenchControlLease lease = new WorkbenchControlLease(); + lease.attachUi(); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + lease.requestControl("Copilot"); + lease.setCurrentAction("Playing the live scenario."); + }); + assertTrue(lease.snapshot().bannerText().contains("Copilot")); + assertTrue(lease.snapshot().bannerText().contains("Playing the live scenario.")); + + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread waiter = new Thread(() -> { + try { + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + lease.awaitPermission(new WorkbenchPermissionRequest( + "perm-1", + WorkbenchPermissionKind.SAVE, + "Copy these live steps into file demo.feature / scenario Demo?", + Path.of("demo.feature").toString(), + "Demo" + )); + }); + } catch (Throwable thrown) { + failure.set(thrown); + } finally { + finished.countDown(); + } + }, "lease-permission-wait"); + waiter.setDaemon(true); + waiter.start(); + + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> lease.snapshot().pending().isPresent(), 2_000); + lease.takeControl(); + assertTrue(finished.await(2, TimeUnit.SECONDS)); + assertTrue(failure.get() instanceof WorkbenchPermissionCancelledException); + assertTrue(lease.snapshot().humanHolds()); + assertTrue(lease.snapshot().pending().isEmpty()); + } + + @Test + void allowAndDenyCompletePermissionWaits() throws Exception { + WorkbenchControlLease lease = new WorkbenchControlLease(); + lease.attachUi(); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> lease.requestControl("Copilot")); + + CountDownLatch waiting = new CountDownLatch(1); + AtomicReference decision = new AtomicReference<>(); + Thread waiter = new Thread(() -> { + decision.set(WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> { + waiting.countDown(); + return lease.awaitPermission(new WorkbenchPermissionRequest( + "perm-2", + WorkbenchPermissionKind.SAVE, + "Copy?", + Path.of("a.feature").toString(), + "A" + )); + })); + }, "lease-allow"); + waiter.setDaemon(true); + waiter.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waitUntil(() -> lease.snapshot().pending().isPresent(), 2_000); + lease.answerPermission("perm-2", false); + waiter.join(2_000); + assertEquals(WorkbenchPermissionDecision.DENY, decision.get()); + } + + @Test + void headlessPermissionIsGrantedByTheExplicitToolCall() { + WorkbenchControlLease lease = new WorkbenchControlLease(); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> lease.requestControl("stdio")); + WorkbenchPermissionDecision decision = WorkbenchCallContext.callAs( + WorkbenchLeaseHolder.AGENT, + () -> lease.awaitPermission(new WorkbenchPermissionRequest( + "perm-3", + WorkbenchPermissionKind.SAVE, + "Copy?", + Path.of("a.feature").toString(), + "A" + )) + ); + assertEquals(WorkbenchPermissionDecision.ALLOW, decision); + assertFalse(lease.snapshot().uiAttached()); + } + + private static void waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("Timed out waiting for lease condition."); + } + Thread.sleep(10); + } + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java new file mode 100644 index 00000000..b31fec6e --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java @@ -0,0 +1,98 @@ +package tools.dscode.workbench.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.workbench.WorkbenchController; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchAttachServerTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @TempDir + Path project; + + @Test + void writesLocalhostAttachStateAndRequiresTheSessionToken() throws Exception { + try (WorkbenchController controller = new WorkbenchController(project); + WorkbenchAttachServer server = WorkbenchAttachServer.start(controller, project)) { + Path stateFile = WorkbenchAttachServer.attachStateFile(project); + assertTrue(Files.isRegularFile(stateFile)); + JsonNode state = JSON.readTree(Files.readString(stateFile)); + assertEquals(server.url(), state.get("url").asText()); + assertEquals(server.token(), state.get("token").asText()); + assertTrue(server.url().startsWith("http://127.0.0.1:")); + assertEquals("ui-attach", state.get("mode").asText()); + + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + HttpResponse denied = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/lease")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(401, denied.statusCode()); + + HttpResponse lease = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/lease")) + .header("Authorization", "Bearer " + server.token()) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(200, lease.statusCode()); + assertTrue(lease.body().contains("\"holder\":\"HUMAN\"")); + + HttpResponse requested = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/tools/workbench_request_control")) + .header("Authorization", "Bearer " + server.token()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"agentName\":\"Copilot\"}")) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(200, requested.statusCode()); + assertTrue(requested.body().contains("\"holder\":\"AGENT\"")); + assertTrue(requested.body().contains("Copilot")); + } + assertTrue(Files.notExists(WorkbenchAttachServer.attachStateFile(project))); + } + + @Test + void mutatingToolWithoutLeaseFailsClearly() throws Exception { + try (WorkbenchController controller = new WorkbenchController(project); + WorkbenchAttachServer server = WorkbenchAttachServer.start(controller, project)) { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/tools/workbench_player_replace_document")) + .header("X-Workbench-Token", server.token()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"text\":\"Given stay\"}")) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(400, response.statusCode()); + assertTrue(response.body().contains("workbench_request_control")); + } + } + + @Test + void headlessStdioLeaseDoesNotRequireABanner() { + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("stdio")); + assertTrue(controller.controlLeaseSnapshot().agentHolds()); + org.junit.jupiter.api.Assertions.assertFalse(controller.controlLeaseSnapshot().uiAttached()); + } + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java index bc8ec522..a1096472 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java @@ -64,6 +64,9 @@ void packagedServerInitializesListsToolsInvokesControllerAndKeepsStdoutProtocolO assertTrue(toolNames.contains("workbench_breakpoint_add")); assertTrue(toolNames.contains("workbench_step_override_compile")); assertTrue(toolNames.contains("workbench_step_override_clear")); + assertTrue(toolNames.contains("workbench_request_control")); + assertTrue(toolNames.contains("workbench_player_state")); + assertTrue(toolNames.contains("workbench_request_save")); JsonNode status = harness.toolCall(3, "workbench_worker_status", "{}"); assertFalse(status.at("/result/isError").asBoolean()); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java new file mode 100644 index 00000000..7545fc63 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java @@ -0,0 +1,98 @@ +package tools.dscode.workbench.player; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LiveFeatureSaveTest { + @TempDir + Path project; + + @Test + void demoBufferIsUnsavableAndLoadDoesNotWriteFiles() throws Exception { + Path feature = project.resolve("login.feature"); + Files.writeString(feature, """ + Feature: Sign in + Scenario: Valid password + Given a user + Scenario: Locked account + Given a lock + """); + String original = Files.readString(feature); + LivePlaybackCoordinator playback = new LivePlaybackCoordinator(LiveScenarioPlayer.interactiveBuffer()); + WorkbenchSavePreview demo = LiveFeatureSave.preview(playback); + assertFalse(demo.savable()); + assertTrue(demo.summary().contains("session-only")); + + playback.loadScenario( + List.of("Feature: Sign in", "", "Scenario: Locked account", " Given a lock"), + feature, + "Locked account", + 4, + 5 + ); + assertEquals(original, Files.readString(feature)); + assertTrue(LiveFeatureSave.preview(playback).savable()); + } + + @Test + void writeSplicesOnlyTheOriginatingScenario() throws Exception { + Path feature = project.resolve("login.feature"); + Files.writeString(feature, """ + Feature: Sign in + Scenario: Valid password + Given a user + Scenario: Locked account + Given a lock + """); + LivePlaybackCoordinator playback = new LivePlaybackCoordinator(new LiveScenarioPlayer(List.of( + "Feature: Sign in", + "", + "Scenario: Locked account", + " Given a lock", + " And stay locked" + ))); + playback.loadScenario( + List.of( + "Feature: Sign in", + "", + "Scenario: Locked account", + " Given a lock", + " And stay locked" + ), + feature, + "Locked account", + 4, + 5 + ); + + WorkbenchSaveResult result = LiveFeatureSave.write(playback); + assertTrue(result.written()); + String saved = Files.readString(feature); + assertTrue(saved.contains("Scenario: Valid password")); + assertTrue(saved.contains("Given a user")); + assertTrue(saved.contains("And stay locked")); + assertTrue(saved.contains("Scenario: Locked account")); + } + + @Test + void denyPathNeverCallsWrite() throws Exception { + Path feature = project.resolve("only.feature"); + Files.writeString(feature, """ + Feature: Only + Scenario: Keep me + Given original + """); + String original = Files.readString(feature); + WorkbenchSaveResult denied = WorkbenchSaveResult.denied(); + assertFalse(denied.written()); + assertEquals(original, Files.readString(feature)); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java index 7eb718af..7904acb9 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java @@ -62,5 +62,6 @@ void loadingAScenarioReplacesTheLiveBufferWithoutWritingFiles() { coordinator.loadDefaultDemo(); assertTrue(coordinator.player().documentText().contains("navigate to: URL.home")); + assertTrue(coordinator.origin().savable() == false); } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java index ce251b42..f8b6a237 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java @@ -18,6 +18,13 @@ import tools.dscode.control.protocol.ControlBridgeValueResult; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.player.LivePlaybackCoordinator; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchPlayerState; +import tools.dscode.workbench.player.WorkbenchSavePreview; +import tools.dscode.workbench.player.WorkbenchSaveResult; +import tools.dscode.workbench.lease.WorkbenchControlLease; +import tools.dscode.workbench.lease.WorkbenchControlLeaseSnapshot; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.worker.WorkbenchWorkerStatus; @@ -342,6 +349,9 @@ private static final class RecordingServices { private final List calls = new ArrayList<>(); private final List overrides = new ArrayList<>(); private final List breakpoints = new ArrayList<>(); + private final LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); + private final LivePlaybackCoordinator playback = new LivePlaybackCoordinator(player); + private final WorkbenchControlLease lease = new WorkbenchControlLease(); private RuntimeException synchronizationFailure; private WorkbenchWorkerStatus status = new WorkbenchWorkerStatus( false, null, null, null, null, false, null @@ -527,6 +537,49 @@ yield new ControlBridgeServiceCallResult( yield count; } case "projectRoot" -> Path.of("consumer"); + case "player" -> player; + case "playback" -> playback; + case "playerState" -> new WorkbenchPlayerState( + player.documentText(), + player.lines().stream().map(LiveScenarioPlayer.Line::text).toList(), + player.state(), + player.playheadId().isPresent() ? player.playheadId().getAsLong() : null, + player.playheadLine().map(LiveScenarioPlayer.Line::text).orElse(""), + player.selectedId().isPresent() ? player.selectedId().getAsLong() : null, + playback.origin().file() == null ? "" : playback.origin().file().toString(), + playback.origin().scenarioName(), + playback.origin().savable() + ); + case "controlLease" -> lease; + case "controlLeaseSnapshot" -> lease.snapshot(); + case "requestControl" -> lease.requestControl((String) args[0]); + case "releaseControl" -> lease.releaseControl(); + case "takeControl" -> lease.takeControl(); + case "setCurrentAction" -> lease.setCurrentAction((String) args[0]); + case "answerPermission" -> { + lease.answerPermission((String) args[0], (Boolean) args[1]); + yield null; + } + case "attachUi" -> { + lease.attachUi(); + yield null; + } + case "detachUi" -> { + lease.detachUi(); + yield null; + } + case "addLeaseListener" -> { + lease.addListener((java.util.function.Consumer) args[0]); + yield null; + } + case "removeLeaseListener" -> { + lease.removeListener((java.util.function.Consumer) args[0]); + yield null; + } + case "addPlayerListener", "removePlayerListener", "loadPickerScenario", + "loadDefaultDemo", "replaceLiveDocument" -> null; + case "savePreview" -> WorkbenchSavePreview.unsavable("session-only"); + case "requestSave", "commitSave" -> WorkbenchSaveResult.unsavable("session-only"); case "workerLogFiles" -> { calls.add("workerLogFiles"); yield java.util.Optional.empty(); diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md index 0adb407d..eb794478 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/feature-map.md @@ -6,7 +6,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab |---|---|---|---| | Build, publication, Java compatibility, nested controller distribution | `build.gradle`; `settings.gradle`; `pickleball-control-protocol/build.gradle`; `pickleball-workbench/build.gradle`; `src/main/aspectj`; search `verifyStrictControllerIsolation`, `verifyEmbeddedWorkbench`, `shadowJar`, `JavaLanguageVersion` | root tests; protocol `check`; `:pickleball-workbench:test`; artifact/dependency verification; Maven consumer build | `README.md`; `docs/getting-started.md`; `docs/cucumber-compatibility.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records | `verifyProtocolIsolation`; `ControlBridgeClientTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `tools.dscode.workbench.sync`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `tools.dscode.workbench.player`; `tools.dscode.workbench.catalog`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS` | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `GherkinBlockDocumentTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `WorkbenchUiControllerTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/mappings/*`, `/v1/browser/*`, `/v1/services/call`, `/v1/breakpoints*`, `/v1/step-overrides*` | controller-only fake-server `ControlBridgeClientTest`; `control-bridge.feature` tagged `@control-bridge`; `ControlBridgeTestSteps.java`; Workbench `worker-check` / `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/workbench/controller//` extraction | `PickleballWorkbenchLauncherTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | @@ -47,6 +47,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). UI mode writes a localhost attach endpoint to `.pickleball/workbench/attach.json` so an agent joins the same JVM/worker instead of starting a second Workbench. The left-rail picker reads project-owned feature files. The live editor is a Gherkin block WebView over that same player model. Global Play starts from the first executable step; Step Editor **Step** is isolated `executeStep`; **From Here** runs from the selected step; wait-at-end stays in play so Enter can append-and-continue. Mapping property types go through `mappingPut` / `mappingRestore`. Terminal tails existing worker log files. Diagnostic explorer reads retained `reports/diagnostic-runs` artifacts in the repository evidence order. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. For this boundary, scenario validation is limited to the affected `@control-bridge` and/or `@step-override-bridge` tags with `pkb_parallel=80` where practical. Do not run `@all` for Workbench isolation changes. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 1a082cd2..86a1c5f4 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md @@ -478,13 +478,27 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/bridge/ControlBridgeClient.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalog.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigator.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchCallContext.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLease.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchControlLeaseSnapshot.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchLeaseHolder.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionCancelledException.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionDecision.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionKind.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/lease/WorkbenchPermissionRequest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingTreeModel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mapping/MappingValueCodec.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpServer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinBlockDocument.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveFeatureSave.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LivePlaybackCoordinator.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/player/LiveScenarioPlayer.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchPlayerState.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSavePreview.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/player/WorkbenchSaveResult.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchManifest.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java` @@ -521,9 +535,12 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/bridge/ControlBridgeClientTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/catalog/ConsumerFeatureCatalogTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/diagnostics/DiagnosticEvidenceNavigatorTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/lease/WorkbenchControlLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mapping/MappingValueCodecTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchMcpServerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinBlockDocumentTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveFeatureSaveTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LivePlaybackCoordinatorTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/player/LiveScenarioPlayerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchGradleSynchronizerIntegrationTest.java` @@ -531,6 +548,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchControllerLeaseTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchRuntimeBoundaryTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchLiveSessionTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/worker/WorkbenchWorkerManagerTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md index 1b9c6763..d4a79d35 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench-player.md @@ -35,7 +35,7 @@ The center editor is an embedded HTML/JS block editor in JavaFX `WebView`. Block Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so the browser panel stays a Workbench-only Maven dependency that shades into the controller JAR. JDK 21 does not ship a modern browser component. If JavaFX cannot start, the same `LiveScenarioPlayer` buffer remains editable as plain Gherkin text. -The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario. The default demo is a small browser scenario against the Maven consumer local test site: +The initial buffer is Workbench-owned sample content. It is not written back to consumer `.feature` files unless you use **Save** on a picker-loaded scenario and confirm the copy. The default demo is a small browser scenario against the Maven consumer local test site: ```gherkin Feature: Workbench Live Scenario @@ -107,6 +107,17 @@ The Terminal tails the worker stdout/stderr files Workbench already creates unde The explorer is a rewind/play/focus timeline of retained Pickleball diagnostic runs. Screenshot frames are shown with the Gherkin step that was running when they were taken. Denser layers follow the repository evidence order and only open when the retained files exist. If `reports/diagnostic-runs/run-catalog.json` is missing, the panel stays empty and says so. +## Watched-agent control lease + +The live player is a collaborative testing space, not a second editor. Swing and an attached agent share one `LiveScenarioPlayer` in the Workbench controller. + +- A human can work alone: edit/play the live buffer, then **Save** asks before copying into the original scenario in the original `.feature` file. +- An agent attaches to the running UI through `.pickleball/workbench/attach.json` (localhost JSON tools over the same `WorkbenchServices`). It must not start a second Workbench JVM or worker. +- After `workbench_request_control`, Swing play/edit/mapping/save/worker controls lock. A banner shows the agent name and `currentAction`. **Take control** always works and cancels in-flight Save permission waits. +- `workbench_request_save` is the only original-feature write path for the agent. With the UI attached it blocks on Allow/Deny. Deny writes nothing. Headless stdio MCP may hold the lease without a banner; Save is still an explicit tool. + +See [pickleball-workbench.md](pickleball-workbench.md) for attach discovery, tool names, and stdout rules. + ## Focused validation The included consumer `@control-bridge` scenario verifies: @@ -115,7 +126,7 @@ The included consumer `@control-bridge` scenario verifies: - the current ParsingMap catalog contains at least one NodeMap; - a catalog reference resolves back to a live NodeMap. -Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, and the non-empty browser demo seed. +Workbench player/editor unit tests cover picker selection/search, block buffer ↔ player model, click-to-seek, global Play from start, the two Step Editor play actions, wait-at-end / Enter-to-append-and-run, in-place edit of previously executed text, typed Mapping edits through `WorkbenchServices`, the non-empty browser demo seed, control-lease lock/Take control, permission grant/deny, and Save not writing without approval. Workbench changes should continue to use the repository's focused validation policy: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md index 5b8fa58d..e63e0903 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/pickleball-workbench.md @@ -22,7 +22,7 @@ Workbench owns synchronization, `.pickleball/workbench/` disposable state, worke `pickleball-control-protocol` owns only immutable wire records, request/response envelopes, transport constants, capabilities, and version/minimum-version negotiation. Worker-side bridge server/coordinator/bootstrap and all translation to runtime operations remain in Pickleball core. -MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. +MCP and Swing are adapters over the same Workbench service seam. They must not introduce a second runtime implementation. A visible UI keeps one Workbench JVM and one consumer worker. An AI agent attaches to that live session through a localhost HTTP JSON facade; it must not start a second Workbench or a second worker. The canonical worker bridge environment is: @@ -149,7 +149,7 @@ The right side remains Mapping, Terminal, and Diagnostic Log Explorer. Low-level - playhead (the user-visible needle); - player states `STOPPED`, `PAUSED`, `RUNNING`, and `WAITING_FOR_STEP`. -The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live; **Save** writes back to the originating `.feature` file when one was loaded. The Workbench-owned demo has no save path. +The Live Scenario Editor is a session-scoped Gherkin document presented as snap-together blocks. Users can type Gherkin into a block, including text that already ran. Stable line ids are preserved across in-place edits so the player can keep selection, playhead, and execution cursor coherent. Loading a picker scenario replaces the live buffer only. The default remains session/live. **Save** is confirmation-gated: it copies the live scenario into the originating `.feature` file and scenario only after Allow. The Workbench-owned demo has no save path. Workbench never writes `.feature` files on picker load or on Deny. The playhead behaves like an audio-player needle: @@ -242,6 +242,63 @@ Synchronization, worker actions, live bridge calls, Mapping operations, event re The UI is intentionally not a project IDE, generic process manager, generic Maven/Gradle task runner, source navigator, or collaboration system. The Live Scenario Editor is a session-scoped Gherkin player/editor, not a workspace file explorer and not an automatic writer of consumer `.feature` files. +### Watched AI-agent control lease + +Workbench owns one control lease for the live session. Swing and MCP/HTTP adapters share it; the lease is not Swing-only state. + +- Holder is `HUMAN` when the UI is up, or `AGENT` after an attached agent requests control. +- The snapshot also carries the agent display name, `currentAction` banner text, and at most one pending permission request. + +While the human holds the lease, Swing play/edit/mapping/save/worker controls stay enabled. Agent mutating calls fail clearly until `workbench_request_control`. + +While an agent holds the lease, the human can watch the same window. Play, edit, Mapping writes, Save, and worker lifecycle controls lock. The WebView editors stay mounted and become read-only; they are not torn down. A banner names the agent and shows `currentAction`. **Take control** stays enabled. Take control returns the lease to `HUMAN`, unlocks Swing, and fails any in-flight agent permission wait so a blocked Save does not write. + +The agent should update `currentAction` as it works. Playhead, Mapping, Terminal, and screenshots follow because the agent uses the same `LiveScenarioPlayer` / worker as the UI. Testing the live scenario (`executeStep`, play, Mapping reads, evidence) is allowed on the agent lease. Copying the live scenario into the original `.feature` is not; that goes through `workbench_request_save` and waits for Allow/Deny in the Swing banner. + +Human **Save** uses the same service. After a picker scenario was loaded, Swing asks: copy these live steps into file X / scenario Y? Deny writes nothing. The demo buffer stays unsavable. + +### Attaching an agent to a visible UI + +UI mode cannot share process stdout with stdio MCP. Starting `mcp` while the UI is already running would be a second Workbench JVM. Instead, `ui` starts a 127.0.0.1-only JSON attach endpoint over the same `WorkbenchServices` / `WorkbenchMcpTools` methods and writes disposable discovery state: + +```text +.pickleball/workbench/attach.json +``` + +Example: + +```json +{ + "url": "http://127.0.0.1:51234", + "token": "hex-session-token", + "pid": 12345, + "project": "/path/to/maven-consumer-project", + "mode": "ui-attach", + "bind": "127.0.0.1" +} +``` + +A Copilot or other MCP-style client finds that file in the consumer project, then: + +1. `GET {url}/health` — liveness, no token. +2. `GET {url}/lease` and `GET {url}/player` — `Authorization: Bearer ` or `X-Workbench-Token`. +3. `POST {url}/tools/workbench_request_control` with `{"agentName":"Copilot"}`. +4. Use the existing live tools (`workbench_execute_step`, Mapping, evidence, worker) while holding the lease, and `workbench_set_current_action` so the human can watch. +5. `POST {url}/tools/workbench_request_save` to ask to copy the live scenario into the original feature. The call blocks until the human clicks Allow or Deny, or Take control. + +Headless `java -jar pickleball-workbench-.jar mcp ` stays stdio JSON-RPC only. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. + +From `maven-consumer-project`: + +```bash +mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ + -Dexec.classpathScope=test \ + "-Dexec.args=ui ." +``` + +Then point the agent at `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session. + ## MCP stdio Start the lightweight non-Spring MCP server for a synchronized consumer project: @@ -283,9 +340,16 @@ workbench_worker_stop workbench_worker_status ``` -Live runtime and Mapping: +Live runtime, Mapping, and watched-agent control: ```text +workbench_request_control +workbench_release_control +workbench_set_current_action +workbench_control_lease +workbench_player_state +workbench_player_replace_document +workbench_request_save workbench_execute_step workbench_mapping_get workbench_mapping_put @@ -324,6 +388,8 @@ workbench_step_override_clear `workbench_step_override_compile` sends the Java source template to the consumer worker. The source must contain `{{CLASS_NAME}}`; worker-side Pickleball remains responsible for compilation, generated classloaders, matching, replacement, captures, and execution. +Mutating live tools require the agent control lease. `workbench_request_save` never writes the original feature until the human Allows it in the UI, or until the explicit stdio tool call itself is the headless approval. Deny, Take control, and an unsavable demo buffer leave the file unchanged. + Controller/runtime failures are returned as MCP tool results with `isError=true`. They are not printed as arbitrary protocol output. ## Scope boundary @@ -381,6 +447,10 @@ Use the UI-owned worker for runtime checks; do not run `worker-check` or `live-c 12. Verify Diagnostic Log Explorer lists retained runs from `reports/diagnostic-runs` only, or shows an honest empty state. 13. Verify **Tools > Advanced Controls** still exposes Status, Recent Events, Step Overrides, Evidence, and Breakpoints. 14. Verify blocking runtime actions leave the Swing UI responsive. +15. Load a picker scenario, click **Save**, and cancel the confirmation; the original `.feature` file must be unchanged. Confirming copies only that scenario back into the originating file. +16. Attach an agent to `.pickleball/workbench/attach.json`, call `workbench_request_control`, and verify the banner plus locked play/edit/mapping/save/worker controls. **Take control** remains enabled. +17. While the agent holds the lease, `workbench_request_save` shows Allow/Deny. Deny writes nothing. Take control cancels the wait without writing. +18. The default demo remains unsavable for both human Save and agent `workbench_request_save`. ## Regression