From 3fdf27934b7185b07c5d4e0be35521675b24d87e Mon Sep 17 00:00:00 2001 From: ds-code-t Date: Mon, 21 Sep 2026 12:50:32 -0700 Subject: [PATCH 01/23] Bottom line: Pickleball 2.1.14 is on a new branch with version stamps in the build and consumer POM. Where: created branch 2.1.14 from master; version is 2.1.14 in root build.gradle and consumer pickleball.version. Changed: - build.gradle, maven-consumer-project/pom.xml: 2.1.13 -> 2.1.14 --- build.gradle | 2 +- maven-consumer-project/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 073cbe0a7..7501b8e93 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ plugins { } group = 'tools.dscode' -version = '2.1.13' +version = '2.1.14' repositories { mavenCentral() diff --git a/maven-consumer-project/pom.xml b/maven-consumer-project/pom.xml index 187cd86db..ba46e7e62 100644 --- a/maven-consumer-project/pom.xml +++ b/maven-consumer-project/pom.xml @@ -13,7 +13,7 @@ UTF-8 21 - 2.1.13 + 2.1.14 From cda8ebc3c01869bffb5d2e6d932143856aed37a5 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 01:53:25 -0700 Subject: [PATCH 02/23] Bottom line: pkb_example keeps specific Examples rows after tags and name selection. Absent or blank leaves the selection unchanged. A list such as "1 2 5 3.4 7-11" keeps those source rows in each selected scenario. Discover, confirm, and isolate accept --example and store it in pkb_runvars. Isolate still forces pkb_parallel=1. --- AGENTS.md | 2 +- build.gradle | 3 + docs/agent/feature-map.md | 4 +- docs/agent/repository-index.md | 3 + docs/ai-run-configuration.md | 2 +- docs/configuration.md | 21 + docs/consumer-agent-guide.md | 14 +- docs/consumer-project.md | 7 +- docs/pickleball-workbench.md | 9 +- .../.github/copilot-instructions.md | 2 +- maven-consumer-project/AGENTS.md | 2 +- .../InternalFrameworkTestSteps.java | 2 + .../launcher/WorkbenchAgentCommandChecks.java | 13 +- .../testengine/ExampleRowFilterChecks.java | 266 +++++++++++ .../workbench/WorkbenchApplication.java | 38 +- .../discover/LastDiscoverSnapshot.java | 18 +- .../workbench/WorkbenchApplicationTest.java | 10 + .../discover/LastDiscoverSnapshotTest.java | 47 ++ .../modularexecutions/CucumberScanUtil.java | 32 +- .../diagnostic/AgentDiscoverPlanner.java | 46 +- .../launcher/WorkbenchAgentCommands.java | 8 +- .../dscode/launcher/WorkbenchCommandLine.java | 47 +- .../launcher/WorkbenchSessionCommands.java | 33 +- .../dscode/testengine/DynamicSuiteEngine.java | 32 +- .../dscode/testengine/ExampleRowFilter.java | 384 ++++++++++++++++ .../tools/dscode/testengine/PKB_props.java | 3 + .../pickleball/guidance/AGENT-GUIDE.md | 14 +- .../guidance/docs/agent/feature-map.md | 4 +- .../guidance/docs/agent/repository-index.md | 3 + .../guidance/docs/ai-run-configuration.md | 2 +- .../pickleball/guidance/docs/configuration.md | 21 + .../guidance/docs/consumer-agent-guide.md | 14 +- .../guidance/docs/consumer-project.md | 7 +- .../guidance/docs/pickleball-workbench.md | 9 +- .../diagnostic/AgentDiscoverPlannerTest.java | 26 ++ .../PickleballWorkbenchLauncherTest.java | 6 + .../launcher/WorkbenchCommandLineTest.java | 40 ++ .../WorkbenchSessionCommandsTest.java | 9 +- .../testengine/ExampleRowFilterTest.java | 426 ++++++++++++++++++ 39 files changed, 1545 insertions(+), 84 deletions(-) create mode 100644 maven-consumer-project/src/test/java/tools/dscode/testengine/ExampleRowFilterChecks.java create mode 100644 src/main/java/tools/dscode/testengine/ExampleRowFilter.java create mode 100644 src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java diff --git a/AGENTS.md b/AGENTS.md index a96fa136c..324ea9c4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ For routine diagnostic utility operations, prefer `tools.dscode.common.reporting ### Controlled diagnostic reruns -When an AI agent launches Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative test-run input. Put intentional selection and execution RunVars such as `pkb_tags`, `pkb_name`, browser, reporting/logging controls, and other non-secret overrides in `pkb_runvars` rather than relying on ambient optional defaults. Use ordinary JVM `-Dpkb_*` RunVars or `pkb_profile` instead only when the task specifically needs to exercise normal configuration/profile precedence or the user explicitly requests those semantics. Protected secret values and diagnostic lineage remain separate. +When an AI agent launches Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative test-run input. Put intentional selection and execution RunVars such as `pkb_tags`, `pkb_name`, `pkb_example`, browser, reporting/logging controls, and other non-secret overrides in `pkb_runvars` rather than relying on ambient optional defaults. Use ordinary JVM `-Dpkb_*` RunVars or `pkb_profile` instead only when the task specifically needs to exercise normal configuration/profile precedence or the user explicitly requests those semantics. Protected secret values and diagnostic lineage remain separate. Optional sealed `pkb_overriderunvars` freezes a complete already-resolved RunVar snapshot for that execution only. When it is absent or blank (and there are no expanded `pkb_overriderunvars.*` members), resolution is unchanged. When it is non-blank, that run ignores files, defaults, JVM `-D pkb_*` RunVars, profiles, `pkb_runvars`, execution-context inheritance, and templates for the Pickleball RunVar set. Maven/JVM/env still exist as the process; they must not change the Pickleball RunVar set. This is ignore-sources, not erase-process. The six context keys (`pkb_glue`, `pkb_features`, `pkb_datapath`, `pkb_callpath`, `pkb_componentpath`, `pkb_configpath`) must be present (blank tombstones allowed; missing keys are errors). No templates in the sealed bag. Sealed + `pkb_runvars` or a composing `pkb_profile` is a configuration error. `pkb_parallel=auto` is the only allowed derived mutation. Dry-run with `PKB_props.resolveRunVars` / `DiagnosticCli resolve-runvars` / Workbench `hint` without starting tests, then launch `-Dpkb_overriderunvars=` and compare `runProfileFingerprint`. `pkb_overriderunvars` is INPUT; `pkb_run_profile` remains derived OUTPUT only. diff --git a/build.gradle b/build.gradle index 7501b8e93..fe9e9b3c3 100644 --- a/build.gradle +++ b/build.gradle @@ -90,6 +90,9 @@ dependencies { api("org.junit.platform:junit-platform-launcher:1.13.4") api("org.junit.jupiter:junit-jupiter-api:5.13.4") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") + // cucumber-core is excluded from the test runtime so woven classes win. + // That exclude also drops transitives only cucumber-core requests. + testRuntimeOnly("io.cucumber:tag-expressions") api("com.google.guava:guava:33.5.0-jre") api("com.fasterxml.jackson.core:jackson-databind:2.20.0") diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index f1bff22e3..b9426c14a 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`; public CLI `--tags` / `--name` / `--retention=all\|failed\|none` on discover/confirm/hint (Discover default `failed`) | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -18,7 +18,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index ae2cabbf0..bbf44cbe5 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -387,6 +387,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/testengine/DynamicSuiteConfigUtils.java` - `src/main/java/tools/dscode/testengine/DynamicSuiteEngine.java` - `src/main/java/tools/dscode/testengine/EngineFilterBootstrap.java` +- `src/main/java/tools/dscode/testengine/ExampleRowFilter.java` - `src/main/java/tools/dscode/testengine/MergedConfigurationParameters.java` - `src/main/java/tools/dscode/testengine/PickleballLauncherSessionListener.java` - `src/main/java/tools/dscode/testengine/PickleballProfiles.java` @@ -416,6 +417,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java` - `src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` +- `src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java` ## Control API module @@ -666,6 +668,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/tools/dscode/coredefinitions/ModularScenariosChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorChecks.java` +- `maven-consumer-project/src/test/java/tools/dscode/testengine/ExampleRowFilterChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/testengine/PkbPropertyValueNormalizerChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/testengine/ProfileConfigurationChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/testengine/RunVarOverrideChecks.java` diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index 800cfa71e..3bb897e95 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -440,7 +440,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -plus the narrowest useful `pkb_tags` / `pkb_name`. Multi-scenario Discover/Confirm use that high parallelism. Live isolate stays one paused scenario on a headless CLI session started with Maven-exec `isolate`. +plus the narrowest useful `pkb_tags` / `pkb_name`. An agent can also pass `pkb_example`, or `confirm` / `isolate --example=...`, to run one or more Examples rows without the GUI. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. Live isolate stays one paused scenario (`pkb_parallel=1`) on a headless CLI session started with Maven-exec `isolate`. After Discover, inspect `pkb_run_profile` from `run-catalog.json`, `run-index.json`, or `summary.json`. Confirm and live isolate replay that retained profile through `pkb_runvars` (LastDiscoverSnapshot) unless the snapshot is marked sealed, in which case the next worker launch uses `-Dpkb_overriderunvars=`. If there is no prior Discover snapshot, Workbench says so; it does not silently re-resolve from project defaults. The Workbench sealed-RunVars panel, if unused, changes no behavior; Apply writes a sealed snapshot for the **next** launch and does not mutate an in-flight worker. diff --git a/docs/configuration.md b/docs/configuration.md index c1dc966ee..1dd6b68d6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -358,6 +358,7 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_configpath` | `configs` | source behind the stable `configs` mapping | | `pkb_tags` | `@smoke and not @slow` | Cucumber tag expression | | `pkb_name` | `Checkout.*` | scenario-name expression | +| `pkb_example` | `1 2 5 3.4 7-11` | Examples-row filter applied after tags and name. Not a tag | | `pkb_environment` | `QA` | project environment label | | `pkb_browser` | `chrome` | browser configuration name looked up under the `configs` mapping (`CHROME_HEADLESS` uses the consumer yaml when present, otherwise Pickleball's bundled headless Chrome) | | `pkb_profile` | `qa,browser_firefox` | selected named profile(s) | @@ -374,6 +375,24 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_platformlog` | `default`, `default+git`, `none`, etc. | platform/caller log stamps | | `pkb_gitsnapshot` | `metadata`, `diff`, `none` | diagnostic Git/source provenance | +## Example row filter + +`pkb_example` keeps specific Examples rows from scenarios that `pkb_tags`, `pkb_name`, and, on a component or `listPickles` scan, `pkb_featurename` have already selected. Where those filters select scenarios, `pkb_example` runs after them. It is not a tag and it does not extend Cucumber tag expressions (`and` / `or` / `not`). It has no `cucumber.filter` alias. `pkb_name` still matches the Scenario or Scenario Outline title and still selects every row until `pkb_example` runs. A normal Maven run applies the same filter after Cucumber's tag and name filters. It does not start honoring `pkb_featurename` on that engine path. + +Absent or blank `pkb_example` adds no filter. + +Tokens are separated by whitespace: + +- An integer `N` is overall value row `N` of that scenario, counted straight through every Examples table. The header is not a value row. `5` with two tables of 3 value rows is table 2, row 2. +- `A.B` is table `A`, value row `B` inside that table. `3.5` is the fifth value row of the third Examples table. +- `Low-High` is an inclusive range of overall indexes only. `7-11` is overall rows 7 through 11. A range is never a table range. + +A list mixes these. `1 2 5 3.4 7-11` keeps overall rows 1, 2, and 5, plus table 3 row 4, plus overall rows 7 through 11. Matching rows are emitted in source order. A row named more than once runs once. + +Each selected scenario is filtered on its own. A normal Scenario is one implicit Examples row, so `1` and `1.1` keep it and any other integer drops it. An outline with no matching row contributes nothing. The run does not wrap, borrow a row from another table, or fail only because one outline was shorter than the index. If the filter leaves zero pickles, the run fails the same way an empty tag or name selection fails. Invalid syntax rejects the run. That includes `0`, negatives, leading zeros, `3.`, `.5`, `2.0`, `1.2.3`, `5-3`, `5-`, `-5`, and a range used as a table selector. + +`pkb_order` and `pkb_limit` still apply after this filter. + Other existing `pkb_*` RunVars retain their previous behavior unless specifically documented otherwise. ## Conservative `pkb_parallel` @@ -418,6 +437,8 @@ Pickleball synchronizes its main selection aliases with Cucumber properties, inc - `pkb_tags` ↔ Cucumber tag filter; - `pkb_name` ↔ Cucumber name filter. +`pkb_example` is not one of those aliases. Put it in `pkb_runvars` (or pass `--example` to Discover, Confirm, and isolate). It is not a Cucumber CLI selector. + Normal command-line Cucumber projection remains supported. When controlled direct RunVars are active, projected Cucumber CLI selection values do not mutate the controlled RunVar set; put intended values in `pkb_runvars`. ## ReportPortal aliases and protected values diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 996555d5c..323228902 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -16,8 +16,8 @@ mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.ds Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, and `stop` — change `exec.args` only. -1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. -2. **Confirm** — `-Dexec.args=confirm --tags=... --name=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. +1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. +2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name/example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. 3. **Live debug** — `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each Maven exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. @@ -33,7 +33,7 @@ After Discover has found the failing scenario, `isolate` starts a headless Workb 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. 3. `-Dexec.args=status` or `status `; `-Dexec.args=events`. 4. `-Dexec.args=stop` (or `kill`) when finished. -5. Confirm with Workbench `confirm` (`-Dexec.args=confirm --tags=... --name=...`). Read the pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` when those tools already exist. +5. Confirm with Workbench `confirm` (`-Dexec.args=confirm --tags=... --name=... --example=...`). Read the pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` when those tools already exist. 6. Emit the human handoff with `workbench_investigation_emit` or `DiagnosticCli emit-investigation`. In chat print the six-line bottom-line block, then the `report.html` path. If a UI session is attached, at most two `wb://` links may follow. If no UI, omit `wb://`. `execute-step` / `workbench_execute_step` returns a structured `SUCCESS` / `FAILED` / `UNAVAILABLE` result. A FAILED Gherkin hypothesis does not end the worker and does not fail the paused scenario. `workbench_step_resolve` maps one Gherkin step to its Java definition (or `DYNAMIC` / `OVERRIDE` / `UNMATCHED`) without executing it. Page events with `afterSequence` and a small `limit` (default 100, max 500). Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). Worker restart without rebuild already exists (`workbench_worker_restart`). Step Overrides compile worker-side (`workbench_step_override_compile`). @@ -156,7 +156,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -Use the narrowest `pkb_tags` / `pkb_name` that isolate the failure. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. +Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: @@ -293,9 +293,9 @@ The agent-facing name is Workbench. From a Maven consumer where Pickleball is on ```text mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" "-Dexec.args=hint" -"-Dexec.args=discover [--tags ] [--name ]" -"-Dexec.args=confirm [--tags ] [--name ]" -"-Dexec.args=isolate" +"-Dexec.args=discover [--tags ] [--name ] [--example ]" +"-Dexec.args=confirm [--tags ] [--name ] [--example ]" +"-Dexec.args=isolate [--example ]" "-Dexec.args=execute-step --text='Given stay'" "-Dexec.args=status" "-Dexec.args=events" diff --git a/docs/consumer-project.md b/docs/consumer-project.md index cdc74b8a8..2da793751 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -152,12 +152,15 @@ Controller/protocol migration checks must remain focused: use `@control-bridge` ```bash mvn test -Dpkb_tags="@forms and @state-assertions" mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" +mvn test "-Dpkb_example=1 2 5 3.4 7-11" ``` +`pkb_example` runs after `pkb_tags` and `pkb_name`. It keeps Examples rows. It is not a tag. A normal Scenario counts as row 1. The same selector is `--example` on Workbench `discover`, `confirm`, and `isolate`. + Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a bounded confirmation should not reuse those defaults. Use Workbench `confirm`, or a separate `pkb_runvars` command that honors the browser ladder: ```bash -PickleballWorkbenchLauncher confirm --tags=@the-failing-tag --name='The failing scenario' +PickleballWorkbenchLauncher confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11' ``` After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. @@ -239,7 +242,7 @@ Do not recursively ingest an entire run. ```text mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=hint" "-Dexec.args=discover" -"-Dexec.args=confirm --tags=@the-failing-tag" +"-Dexec.args=confirm --tags=@the-failing-tag --example=1.1" DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli emit-investigation diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 0fb5d4151..0131b7178 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -59,13 +59,16 @@ mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ "-Dexec.args=discover --tags=@smoke" "-Dexec.args=discover --tags=@smoke --retention=all" "-Dexec.args=confirm --tags=@smoke --name='The failing scenario'" -"-Dexec.args=isolate" +"-Dexec.args=confirm --tags=@smoke --example='1 2 5 3.4 7-11'" +"-Dexec.args=isolate --example=1.1" "-Dexec.args=execute-step --text='Given stay'" ``` -Same launcher; only change `-Dexec.args`. `isolate` starts a detached headless session; later execs are one-shot HTTP clients. +Same launcher; only change `-Dexec.args`. `isolate` starts a detached headless session; later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. -`hint`, `discover`, and `confirm` accept `--tags` / `--name` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. +`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. Play still picks one visible row in the Workbench player. Headless selection is `pkb_example`. Do not start the GUI. + +`hint`, `discover`, and `confirm` accept `--tags` / `--name` / `--example` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This retention overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` print a dry-run resolve preview (`PKB_props.resolveRunVars`) that does not start tests. Optional sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map including the six context keys → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. When a LastDiscoverSnapshot is marked sealed, the next worker launch uses `-Dpkb_overriderunvars=`, never `-Dpkb_run_profile=`. Unused, sealed input changes no behavior. diff --git a/maven-consumer-project/.github/copilot-instructions.md b/maven-consumer-project/.github/copilot-instructions.md index 3206487c4..9c7799c1f 100644 --- a/maven-consumer-project/.github/copilot-instructions.md +++ b/maven-consumer-project/.github/copilot-instructions.md @@ -1,5 +1,5 @@ Before any Pickleball work, run `mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. -Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. +Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. For live debug, Maven-exec `isolate` (starts a headless CLI session), then `execute-step`, `status`, `events`, and `stop`. If `workbench_*` tools are already attached they are the same session, not a setup step. diff --git a/maven-consumer-project/AGENTS.md b/maven-consumer-project/AGENTS.md index 3206487c4..9c7799c1f 100644 --- a/maven-consumer-project/AGENTS.md +++ b/maven-consumer-project/AGENTS.md @@ -1,5 +1,5 @@ Before any Pickleball work, run `mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. -Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. +Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. For live debug, Maven-exec `isolate` (starts a headless CLI session), then `execute-step`, `status`, `events`, and `stop`. If `workbench_*` tools are already attached they are the same session, not a setup step. diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java index 29bc1a556..a0abda673 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/InternalFrameworkTestSteps.java @@ -26,6 +26,7 @@ import tools.dscode.coredefinitions.ModularScenariosChecks; import tools.dscode.launcher.WorkbenchAgentCommandChecks; import tools.dscode.parallelutilities.ParallelCountEstimatorChecks; +import tools.dscode.testengine.ExampleRowFilterChecks; import tools.dscode.testengine.PkbPropertyValueNormalizerChecks; import tools.dscode.testengine.ProfileConfigurationChecks; import tools.dscode.testengine.RunVarOverrideChecks; @@ -60,6 +61,7 @@ public static void runInternalPickleballJavaTests() { BusinessTemporalDeltaChecks.class, BusinessTimePostModifierChecks.class, PkbPropertyValueNormalizerChecks.class, + ExampleRowFilterChecks.class, ProfileConfigurationChecks.class, RunVarOverrideChecks.class, ParallelCountEstimatorChecks.class, diff --git a/maven-consumer-project/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandChecks.java b/maven-consumer-project/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandChecks.java index 8e6b2d0c1..bafe4e049 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandChecks.java @@ -46,10 +46,18 @@ void isolateReplayDoesNotReinheritOptionalDefaults() { retained.put(PKB_props.PKB_GLUE, "com.example.pickleball"); retained.put(PKB_props.PKB_REPORTING_MODE, "diagnostic"); - String isolate = AgentDiscoverPlanner.isolateRunVars(retained, "@broken", null); + String isolate = AgentDiscoverPlanner.isolateRunVars(retained, "@broken", null, null, "1 2 5"); assertTrue(isolate.contains("pkb_browser=CHROME_HEADLESS")); assertTrue(isolate.contains("pkb_parallel=1")); + assertTrue(isolate.contains("pkb_example=1 2 5")); assertTrue(isolate.contains("pkb_glue=com.example.pickleball")); + assertFalse(isolate.contains("pkb_parallel=10")); + + String[] forwarded = PickleballWorkbenchLauncher.normalizedArguments( + new String[]{"isolate", "--example=1", "2", "5"} + ); + assertEquals("--example", forwarded[2]); + assertEquals("1 2 5", forwarded[3]); assertFalse(isolate.contains("pkb_run_profile=")); assertFalse(isolate.contains("@all")); assertFalse(isolate.contains("pretty")); @@ -61,9 +69,10 @@ void confirmReplaysSnapshot() { PKB_props.PKB_BROWSER, "GRID_CHROME", PKB_props.PKB_PARALLEL, "6" ); - String confirm = AgentDiscoverPlanner.confirmRunVars(retained, "@one", null); + String confirm = AgentDiscoverPlanner.confirmRunVars(retained, "@one", null, null, "1.1"); assertTrue(confirm.contains("pkb_browser=GRID_CHROME")); assertTrue(confirm.contains("pkb_parallel=6")); assertTrue(confirm.contains("pkb_tags=@one")); + assertTrue(confirm.contains("pkb_example=1.1")); } } diff --git a/maven-consumer-project/src/test/java/tools/dscode/testengine/ExampleRowFilterChecks.java b/maven-consumer-project/src/test/java/tools/dscode/testengine/ExampleRowFilterChecks.java new file mode 100644 index 000000000..aff66dc21 --- /dev/null +++ b/maven-consumer-project/src/test/java/tools/dscode/testengine/ExampleRowFilterChecks.java @@ -0,0 +1,266 @@ +package tools.dscode.testengine; + +import io.cucumber.core.gherkin.Pickle; +import io.cucumber.core.runner.modularexecutions.CucumberScanUtil; +import io.cucumber.core.runner.util.CucumberQueryUtil; +import io.cucumber.messages.types.TableRow; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +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; +import static tools.dscode.testengine.PKB_props.PKB_EXAMPLE; +import static tools.dscode.testengine.PKB_props.PKB_FEATURES; +import static tools.dscode.testengine.PKB_props.PKB_FEATURE_NAME; +import static tools.dscode.testengine.PKB_props.PKB_NAME; +import static tools.dscode.testengine.PKB_props.PKB_TAGS; + +public final class ExampleRowFilterChecks { + private static final String FEATURE = """ + Feature: Example filter + + Scenario: Plain + Given value plain + + Scenario Outline: First outline + Given value + Examples: + | v | + | a | + | b | + | c | + Examples: + | v | + | d | + | e | + | f | + + Scenario Outline: Short outline + Given value + Examples: + | v | + | a | + | b | + | c | + Examples: + | v | + | d | + | e | + | f | + + Scenario Outline: Long outline + Given value + Examples: + | v | + | a | + | b | + | c | + Examples: + | v | + | d | + | e | + | f | + Examples: + | v | + | g | + | h | + | i | + | j | + | k | + + Scenario Outline: Tagged outline + Given value + @skip + Examples: + | v | + | a | + | b | + Examples: + | v | + | c | + """; + + private static final String OTHER = """ + Feature: Other + + Scenario Outline: Other outline + Given value + Examples: + | v | + | a | + | b | + | c | + | d | + | e | + | f | + | g | + """; + + @Test + void absentAndBlankExampleAddNoFilter() throws IOException { + withFeatures((feature, other) -> { + assertTrue(CucumberScanUtil.listPickles(Map.of(PKB_FEATURES, feature.toString())).isEmpty()); + List selected = named(scan(feature.toString(), null, null, ".*", null)); + List blank = named(scan(feature.toString(), " ", null, ".*", null)); + assertEquals(selected, blank); + assertTrue(selected.contains("Plain:plain")); + }); + } + + @Test + void integerFiveIsTheSecondRowOfTheSecondTable() throws IOException { + withFeatures((feature, other) -> { + List rows = scan(feature.toString(), "5", null, "First outline", null); + assertEquals(List.of("First outline:e"), named(rows)); + ExampleRowFilter.Coordinates coordinates = ExampleRowFilter.coordinatesOf(rows.getFirst()); + assertEquals(5, coordinates.overall()); + assertEquals(2, coordinates.table()); + assertEquals(2, coordinates.row()); + }); + } + + @Test + void tableRowAndListSelectionKeepSourceOrder() throws IOException { + withFeatures((feature, other) -> { + assertEquals(List.of("Plain:plain"), named(scan(feature.toString(), "1.1", null, "Plain", null))); + assertEquals( + List.of("a", "b", "e", "g", "h", "i", "j", "k"), + cells(scan(feature.toString(), "1 2 5 3.4 7-11", null, "Long outline", null)) + ); + assertEquals( + List.of("a", "e"), + cells(scan(feature.toString(), "5 1 5 2.2", null, "Long outline", null)) + ); + assertEquals( + List.of("First outline:e", "Short outline:e", "Long outline:e"), + named(scan(feature.toString(), "5", null, null, null)) + ); + }); + } + + @Test + void shortOutlineContributesNothingAndZeroMatchesFails() throws IOException { + withFeatures((feature, other) -> { + List shortOnly = scan(feature.toString(), null, null, "Short outline", null); + assertTrue(ExampleRowFilter.filterPickles("7", shortOnly).isEmpty()); + assertEquals( + List.of("Long outline:g"), + named(scan(feature.toString(), "7", null, "Short outline|Long outline", null)) + ); + IllegalArgumentException none = assertThrows( + IllegalArgumentException.class, + () -> scan(feature.toString(), "99", null, ".*", null) + ); + assertTrue(none.getMessage().startsWith("No scenarios matched the provided filters:")); + assertTrue(none.getMessage().contains("example=[99]")); + IllegalArgumentException plain = assertThrows( + IllegalArgumentException.class, + () -> scan(feature.toString(), "2", null, "Plain", null) + ); + assertTrue(plain.getMessage().contains("example=[2]")); + }); + } + + @Test + void invalidTokensAndTagIndexesFailClosed() throws IOException { + for (String token : List.of("0", "5-3", "2.0", "3.", "1.2.3")) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> ExampleRowFilter.parse(token)); + assertTrue(failure.getMessage().contains("'" + token + "'")); + assertFalse(failure.getMessage().startsWith("No scenarios matched")); + } + withFeatures((feature, other) -> { + IllegalArgumentException partial = assertThrows( + IllegalArgumentException.class, + () -> scan(feature.toString(), "1 2 0", null, "Long outline", null) + ); + assertTrue(partial.getMessage().contains("'0'")); + IllegalArgumentException renamed = assertThrows( + IllegalArgumentException.class, + () -> scan(feature.toString(), "1", "not @skip", "Tagged outline", null) + ); + assertTrue(renamed.getMessage().startsWith("No scenarios matched the provided filters:")); + assertEquals( + List.of("Tagged outline:c"), + named(scan(feature.toString(), "2.1", "not @skip", "Tagged outline", null)) + ); + String both = feature + "," + other; + assertEquals(List.of("Long outline:g"), named(scan(both, "7", null, null, "Example filter"))); + }); + } + + private static void withFeatures(FeatureCase body) throws IOException { + Path dir = Files.createTempDirectory("pkb-example-"); + try { + Path feature = dir.resolve("example-filter.feature"); + Path other = dir.resolve("other.feature"); + Files.writeString(feature, FEATURE); + Files.writeString(other, OTHER); + CucumberScanUtil.clearCache(); + body.run(feature, other); + } finally { + deleteTree(dir); + } + } + + private static List scan(String features, String example, String tags, String name, String featureName) { + CucumberScanUtil.clearCache(); + Map props = new HashMap<>(); + props.put(PKB_FEATURES, features); + if (example != null) props.put(PKB_EXAMPLE, example); + if (tags != null) props.put(PKB_TAGS, tags); + if (name != null) props.put(PKB_NAME, name); + if (featureName != null) props.put(PKB_FEATURE_NAME, featureName); + return CucumberScanUtil.listPickles(props); + } + + private static List named(List pickles) { + List names = new ArrayList<>(); + for (Pickle pickle : pickles) { + names.add(pickle.getName() + ":" + cell(pickle)); + } + return names; + } + + private static List cells(List pickles) { + List values = new ArrayList<>(); + for (Pickle pickle : pickles) { + values.add(cell(pickle)); + } + return values; + } + + private static String cell(Pickle pickle) { + TableRow exampleRow = CucumberQueryUtil.describe(pickle).exampleRow; + if (exampleRow != null && exampleRow.getCells() != null && !exampleRow.getCells().isEmpty()) { + return exampleRow.getCells().getFirst().getValue(); + } + String text = pickle.getSteps().getFirst().getText(); + int space = text.lastIndexOf(' '); + return space < 0 ? text : text.substring(space + 1); + } + + private static void deleteTree(Path root) throws IOException { + if (root == null || !Files.exists(root)) return; + try (Stream walk = Files.walk(root)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + + @FunctionalInterface + private interface FeatureCase { + void run(Path feature, Path other) throws IOException; + } +} 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 7a3f4a683..1b4bf1371 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java @@ -193,7 +193,7 @@ static int isolate(String[] args, PrintStream out, PrintStream err, boolean inte Map workerProperties; try { workerProperties = tools.dscode.workbench.discover.LastDiscoverSnapshot.workerSystemProperties( - parsed.project(), parsed.tags(), parsed.name() + parsed.project(), parsed.tags(), parsed.name(), parsed.example() ); } catch (RuntimeException failure) { err.println(failure.getMessage()); @@ -242,7 +242,7 @@ static int session(String[] args, PrintStream out, PrintStream err) { Map workerProperties; try { workerProperties = tools.dscode.workbench.discover.LastDiscoverSnapshot.workerSystemProperties( - parsed.project(), parsed.tags(), parsed.name() + parsed.project(), parsed.tags(), parsed.name(), parsed.example() ); } catch (RuntimeException failure) { err.println(failure.getMessage()); @@ -323,17 +323,21 @@ static boolean stdinIsInteractiveTty() { } } - private record IsolateArgs(Path project, String tags, String name) { + record IsolateArgs(Path project, String tags, String name, String example) { } - private static IsolateArgs projectCommandArgs(String[] args, String command) { + static IsolateArgs projectCommandArgs(String[] args, String command) { if (args.length < 2 || args[1].isBlank() || args[1].startsWith("-")) { throw new IllegalArgumentException( - "Usage: pickleball-workbench " + command + " [--tags ] [--name ]" + "Usage: pickleball-workbench " + command + + " [--tags ] [--name ] [--example ]" ); } String tags = null; String name = null; + String example = null; + boolean absorbName = false; + boolean absorbExample = false; for (int index = 2; index < args.length; index++) { String token = args[index]; if (token.startsWith("--tags=")) { @@ -342,13 +346,29 @@ private static IsolateArgs projectCommandArgs(String[] args, String command) { tags = args[++index]; } else if (token.startsWith("--name=")) { name = token.substring("--name=".length()); + absorbName = true; + absorbExample = false; } else if ("--name".equals(token) && index + 1 < args.length) { name = args[++index]; - } else if (name != null && !token.startsWith("-")) { + absorbName = true; + absorbExample = false; + } else if (token.startsWith("--example=")) { + String value = token.substring("--example=".length()); + example = value.isBlank() ? null : value; + absorbExample = example != null; + absorbName = false; + } else if ("--example".equals(token) && index + 1 < args.length) { + String value = args[++index]; + example = value == null || value.isBlank() ? null : value; + absorbExample = example != null; + absorbName = false; + } else if (absorbExample && example != null && !token.startsWith("-")) { + example = example + " " + token; + } else if (absorbName && name != null && !token.startsWith("-")) { name = name + " " + token; } } - return new IsolateArgs(Path.of(args[1]), tags, name); + return new IsolateArgs(Path.of(args[1]), tags, name, example); } private static int workerCheck(String[] args, PrintStream out) { @@ -602,8 +622,8 @@ private static void printUsage(PrintStream out) { out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication status "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication worker-check "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication live-check "); - out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication isolate [--tags ] [--name ]"); - out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication session [--tags ] [--name ]"); + out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication isolate [--tags ] [--name ] [--example ]"); + out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication session [--tags ] [--name ] [--example ]"); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication mcp "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication ui "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication --version"); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/discover/LastDiscoverSnapshot.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/discover/LastDiscoverSnapshot.java index 758f71c0d..297ff3fb0 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/discover/LastDiscoverSnapshot.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/discover/LastDiscoverSnapshot.java @@ -72,11 +72,20 @@ public static Snapshot require(Path projectRoot) { } public static Map workerSystemProperties(Path projectRoot, String tags, String name) { + return workerSystemProperties(projectRoot, tags, name, null); + } + + public static Map workerSystemProperties( + Path projectRoot, + String tags, + String name, + String example + ) { Snapshot snapshot = require(projectRoot); if (snapshot.sealed()) { - return Map.of("pkb_overriderunvars", replay(snapshot, true, tags, name)); + return Map.of("pkb_overriderunvars", replay(snapshot, true, tags, name, example)); } - return Map.of("pkb_runvars", replay(snapshot, true, tags, name)); + return Map.of("pkb_runvars", replay(snapshot, true, tags, name, example)); } public static Map workerSystemPropertiesIfPresent(Path projectRoot) { @@ -89,6 +98,10 @@ public static Map workerSystemPropertiesIfPresent(Path projectRo } public static String replay(Snapshot snapshot, boolean isolate, String tags, String name) { + return replay(snapshot, isolate, tags, name, null); + } + + public static String replay(Snapshot snapshot, boolean isolate, String tags, String name, String example) { Map values = new TreeMap<>(); if (snapshot.runVars() != null) values.putAll(snapshot.runVars()); if (values.isEmpty() && snapshot.runProfile() != null && !snapshot.runProfile().isBlank()) { @@ -98,6 +111,7 @@ public static String replay(Snapshot snapshot, boolean isolate, String tags, Str if (isolate) values.put("pkb_parallel", "1"); if (tags != null && !tags.isBlank()) values.put("pkb_tags", tags.trim()); if (name != null && !name.isBlank()) values.put("pkb_name", name.trim()); + if (example != null && !example.isBlank()) values.put("pkb_example", example.trim()); return serializeCompact(values); } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java index 228fb1670..9b64f5938 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java @@ -210,6 +210,16 @@ void mcpRequiresExactlyOneProjectBeforeTouchingProtocolStreams() { .contains("Usage: pickleball-workbench mcp ")); } + @Test + void projectCommandArgsForwardsExampleWithoutAbsorbingItIntoName() { + WorkbenchApplication.IsolateArgs parsed = WorkbenchApplication.projectCommandArgs( + new String[]{"isolate", "C:/consumer", "--name=The", "failing", "scenario", "--example=1", "2", "5"}, + "isolate" + ); + assertEquals("The failing scenario", parsed.name()); + assertEquals("1 2 5", parsed.example()); + } + private static Output run(String... args) { ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/discover/LastDiscoverSnapshotTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/discover/LastDiscoverSnapshotTest.java index 2a5640404..83452b4ac 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/discover/LastDiscoverSnapshotTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/discover/LastDiscoverSnapshotTest.java @@ -62,6 +62,53 @@ void isolateReplayUsesRetainedRunVarsAndForcesParallelOne() throws Exception { assertFalse(runVars.contains("@all")); } + @Test + void isolateReplayPutsExampleInRunVarsAndKeepsParallelOne() throws Exception { + Path file = LastDiscoverSnapshot.file(tempDir); + Files.createDirectories(file.getParent()); + Files.writeString(file, """ + { + "schemaVersion": 1, + "source": "workbench-discover", + "runId": "run-1", + "runProfile": "pkb_browser=CHROME_HEADLESS, pkb_parallel=12", + "runVars": { + "pkb_browser": "CHROME_HEADLESS", + "pkb_parallel": "12" + } + } + """); + + Map properties = LastDiscoverSnapshot.workerSystemProperties( + tempDir, "@broken", "Failing scenario", "1 2 5 3.4 7-11" + ); + String runVars = properties.get("pkb_runvars"); + assertTrue(runVars.contains("pkb_example=1 2 5 3.4 7-11")); + assertTrue(runVars.contains("pkb_parallel=1")); + assertFalse(runVars.contains("pkb_parallel=12")); + + Files.writeString(file, """ + { + "schemaVersion": 1, + "source": "workbench-sealed", + "runId": "run-1", + "runProfile": "pkb_browser=CHROME_HEADLESS, pkb_parallel=12", + "runVars": { + "pkb_browser": "CHROME_HEADLESS", + "pkb_parallel": "12" + }, + "sealed": true + } + """); + Map sealed = LastDiscoverSnapshot.workerSystemProperties( + tempDir, "@broken", null, "1.1" + ); + assertTrue(sealed.containsKey("pkb_overriderunvars")); + assertFalse(sealed.containsKey("pkb_runvars")); + assertTrue(sealed.get("pkb_overriderunvars").contains("pkb_example=1.1")); + assertTrue(sealed.get("pkb_overriderunvars").contains("pkb_parallel=1")); + } + @Test void sealedSnapshotReplaysOverrideNotRunProfileOrRunvars() throws Exception { Path file = LastDiscoverSnapshot.file(tempDir); diff --git a/src/main/java/io/cucumber/core/runner/modularexecutions/CucumberScanUtil.java b/src/main/java/io/cucumber/core/runner/modularexecutions/CucumberScanUtil.java index c2e40c3a6..92f3bc666 100644 --- a/src/main/java/io/cucumber/core/runner/modularexecutions/CucumberScanUtil.java +++ b/src/main/java/io/cucumber/core/runner/modularexecutions/CucumberScanUtil.java @@ -12,6 +12,7 @@ import io.cucumber.core.runtime.FeaturePathFeatureSupplier; import org.intellij.lang.annotations.Language; import tools.dscode.testengine.DynamicSuiteConfigUtils; +import tools.dscode.testengine.ExampleRowFilter; import java.nio.file.Path; import java.nio.file.Paths; @@ -38,6 +39,7 @@ import static tools.dscode.common.GlobalConstants.COMPONENT_TAG_META_CHAR; import static tools.dscode.common.util.Reflect.getProperty; import static tools.dscode.testengine.DynamicSuiteConfigUtils.getFeaturePaths; +import static tools.dscode.testengine.PKB_props.PKB_EXAMPLE; import static tools.dscode.testengine.PKB_props.PKB_FEATURES; import static tools.dscode.testengine.PKB_props.PKB_FEATURE_NAME; import static tools.dscode.testengine.PKB_props.PKB_LIMIT; @@ -75,6 +77,7 @@ public static List listPicklesByTags(String tagString) { * - pkb_features — comma-separated feature paths/URIs → cucumber.features * - pkb_tags — tag expression → cucumber.filter.tags * - pkb_name — scenario name regex → cucumber.filter.name + * - pkb_example — Examples-row filter applied after tags, name, and feature name. Not a Cucumber alias. * - pkb_order — lexical | reverse | random | random:<seed> → cucumber.execution.order * - pkb_limit — max scenarios to return → cucumber.execution.limit *

@@ -82,9 +85,10 @@ public static List listPicklesByTags(String tagString) { * input map (even if the value is null or blank), that value is used. When neither key is * present, paths default to {@link DynamicSuiteConfigUtils#getFeaturePaths()}. *

- * At least one scenario-level filter (tags, scenario name, or feature name) must have a - * non-blank value; otherwise an empty list is returned. When filters are present but - * nothing matches, {@link IllegalArgumentException} is thrown with filter details. + * At least one scenario-level filter (tags, scenario name, feature name, or example rows) + * must have a non-blank value; otherwise an empty list is returned. When filters are + * present but nothing matches, {@link IllegalArgumentException} is thrown with filter + * details. Invalid {@code pkb_example} syntax is rejected before feature IO. */ public static List listPickles(Map cucumberProps) { Objects.requireNonNull(cucumberProps, "cucumberProps"); @@ -93,6 +97,11 @@ public static List listPickles(Map cucumberProps) { return List.of(); } + String example = trimToNull(cucumberProps.get(PKB_EXAMPLE)); + if (example != null) { + ExampleRowFilter.parse(example); + } + Map effectiveProps = normalizePkbProps(cucumberProps); applyRunTagNormalization(effectiveProps, cucumberProps); String featureName = removeFeatureNameFilter(effectiveProps); @@ -117,7 +126,14 @@ public static List listPickles(Map cucumberProps) { .collect(Collectors.toCollection(ArrayList::new)); if (pickles.isEmpty()) { - throw noScenariosMatchedException(effectiveProps, featureName, featureUris); + throw noScenariosMatchedException(effectiveProps, featureName, featureUris, example); + } + + if (example != null) { + pickles = new ArrayList<>(ExampleRowFilter.filterPickles(example, pickles)); + if (pickles.isEmpty()) { + throw noScenariosMatchedException(effectiveProps, featureName, featureUris, example); + } } return applyOrderAndLimit(options, pickles); @@ -223,7 +239,8 @@ private static boolean hasScenarioLevelFilters(Map inputProps) { || hasNonBlankFilterValue(inputProps, FILTER_TAGS_PROPERTY_NAME) || hasNonBlankFilterValue(inputProps, PKB_NAME) || hasNonBlankFilterValue(inputProps, FILTER_NAME_PROPERTY_NAME) - || hasNonBlankFilterValue(inputProps, PKB_FEATURE_NAME); + || hasNonBlankFilterValue(inputProps, PKB_FEATURE_NAME) + || hasNonBlankFilterValue(inputProps, PKB_EXAMPLE); } private static boolean hasNonBlankFilterValue(Map props, String key) { @@ -237,11 +254,13 @@ private static boolean hasNonBlankFilterValue(Map props, String private static IllegalArgumentException noScenariosMatchedException( Map effectiveProps, String featureName, - List featureUris + List featureUris, + String example ) { StringBuilder message = new StringBuilder("No scenarios matched the provided filters:"); appendFilterDetail(message, "tags", effectiveProps.get(FILTER_TAGS_PROPERTY_NAME)); appendFilterDetail(message, "name", effectiveProps.get(FILTER_NAME_PROPERTY_NAME)); + appendFilterDetail(message, "example", example); appendFilterDetail(message, "featureName", featureName); if (!featureUris.isEmpty()) { message.append(" features=[").append(String.join(",", featureUris)).append(']'); @@ -278,6 +297,7 @@ private static Map normalizePkbProps(Map inputPr props.remove(PKB_TAGS); props.remove(PKB_NAME); + props.remove(PKB_EXAMPLE); props.remove(PKB_ORDER); props.remove(PKB_LIMIT); props.remove(PKB_FEATURES); diff --git a/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java b/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java index c3c156356..bb3da8eb7 100644 --- a/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java +++ b/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java @@ -23,18 +23,28 @@ public record Plan( } public static Plan discover(Path projectRoot, String tags, String name) { - return discover(projectRoot, tags, name, null); + return discover(projectRoot, tags, name, null, null); } public static Plan discover(Path projectRoot, String tags, String name, String retention) { + return discover(projectRoot, tags, name, retention, null); + } + + public static Plan discover( + Path projectRoot, + String tags, + String name, + String retention, + String example + ) { AgentBrowserLadder.Decision browser = AgentBrowserLadder.select(projectRoot); LinkedHashMap values = baseDiscoverVars(browser.browser()); - overlaySelection(values, tags, name, retention); + overlaySelection(values, tags, name, retention, example); return new Plan(projectRoot, browser, PKB_props.serializeRunVars(values), blankToNull(tags), blankToNull(name)); } public static String isolateRunVars(Map retainedProfile, String tags, String name) { - return isolateRunVars(retainedProfile, tags, name, null); + return isolateRunVars(retainedProfile, tags, name, null, null); } public static String isolateRunVars( @@ -42,18 +52,28 @@ public static String isolateRunVars( String tags, String name, String retention + ) { + return isolateRunVars(retainedProfile, tags, name, retention, null); + } + + public static String isolateRunVars( + Map retainedProfile, + String tags, + String name, + String retention, + String example ) { if (retainedProfile == null || retainedProfile.isEmpty()) { throw new IllegalStateException(missingDiscoverSnapshotMessage()); } LinkedHashMap values = copyRunVars(retainedProfile); values.put(PKB_props.PKB_PARALLEL, "1"); - overlaySelection(values, tags, name, retention); + overlaySelection(values, tags, name, retention, example); return PKB_props.serializeRunVars(values); } public static String confirmRunVars(Map retainedProfile, String tags, String name) { - return confirmRunVars(retainedProfile, tags, name, null); + return confirmRunVars(retainedProfile, tags, name, null, null); } public static String confirmRunVars( @@ -61,12 +81,22 @@ public static String confirmRunVars( String tags, String name, String retention + ) { + return confirmRunVars(retainedProfile, tags, name, retention, null); + } + + public static String confirmRunVars( + Map retainedProfile, + String tags, + String name, + String retention, + String example ) { if (retainedProfile == null || retainedProfile.isEmpty()) { throw new IllegalStateException(missingDiscoverSnapshotMessage()); } LinkedHashMap values = copyRunVars(retainedProfile); - overlaySelection(values, tags, name, retention); + overlaySelection(values, tags, name, retention, example); return PKB_props.serializeRunVars(values); } @@ -97,10 +127,12 @@ private static void overlaySelection( LinkedHashMap values, String tags, String name, - String retention + String retention, + String example ) { if (tags != null && !tags.isBlank()) values.put(PKB_props.PKB_TAGS, tags.trim()); if (name != null && !name.isBlank()) values.put(PKB_props.PKB_NAME, name.trim()); + if (example != null && !example.isBlank()) values.put(PKB_props.PKB_EXAMPLE, example.trim()); if (retention != null && !retention.isBlank()) { values.put( PKB_props.PKB_REPORT_RETENTION, diff --git a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java index 3c2fba363..4cec31569 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java @@ -63,7 +63,7 @@ private static int exportGuidance(WorkbenchCommandLine.Parsed parsed, PrintStrea private static int hint(WorkbenchCommandLine.Parsed parsed, PrintStream out) { AgentDiscoverPlanner.Plan plan = AgentDiscoverPlanner.discover( - parsed.project(), parsed.tags(), parsed.name(), parsed.retention() + parsed.project(), parsed.tags(), parsed.name(), parsed.retention(), parsed.example() ); out.println("Recommended complete diagnostic Discover `pkb_runvars` (Workbench honors the project browser ladder; headed Chrome / pretty / @all project defaults do not sneak in):"); out.println("pkb_runvars=" + plan.runVars()); @@ -129,7 +129,7 @@ private static int discover( MavenRunner maven ) { AgentDiscoverPlanner.Plan plan = AgentDiscoverPlanner.discover( - parsed.project(), parsed.tags(), parsed.name(), parsed.retention() + parsed.project(), parsed.tags(), parsed.name(), parsed.retention(), parsed.example() ); out.println("Workbench discover " + plan.browser().reason() + "."); out.println("pkb_runvars=" + plan.runVars()); @@ -147,7 +147,7 @@ private static int confirm( LastDiscoverSnapshot.Snapshot snapshot = LastDiscoverSnapshot.require(parsed.project()); Map retained = LastDiscoverSnapshot.retainedRunVars(snapshot); String runVars = AgentDiscoverPlanner.confirmRunVars( - retained, parsed.tags(), parsed.name(), parsed.retention() + retained, parsed.tags(), parsed.name(), parsed.retention(), parsed.example() ); out.println("Workbench confirm replaying Discover snapshot as pkb_runvars."); out.println("pkb_runvars=" + runVars); @@ -181,7 +181,7 @@ private static int printCatalog( out.println("run-catalog.json: " + latest.catalog()); out.println("retained pkb_run_profile: " + latest.runProfile()); if (writeSnapshot) { - out.println("NEXT: confirm --tags/--name. For live debug: isolate (starts session), then execute-step / status / events / stop."); + out.println("NEXT: confirm --tags/--name/--example. For live debug: isolate (starts session), then execute-step / status / events / stop."); } return mavenExit; } catch (Exception failure) { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java index 4c13d8aad..4045ab86c 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java @@ -30,6 +30,7 @@ record Parsed( Path outputDirectory, String tags, String name, + String example, String retention, String[] forwarded ) { @@ -50,19 +51,22 @@ static boolean isSessionClientCommand(String command) { static Parsed parse(String[] args) { Path cwd = Path.of("").toAbsolutePath().normalize(); if (args == null || args.length == 0) { - return new Parsed("ui", cwd, null, null, null, null, new String[]{"ui", cwd.toString()}); + return new Parsed("ui", cwd, null, null, null, null, null, new String[]{"ui", cwd.toString()}); } String command = args[0]; if ("export-guidance".equals(command)) { Path output = args.length >= 2 && !isFlag(args[1]) ? Path.of(args[1]) : Path.of(".pickleball"); - return new Parsed(command, cwd, output, null, null, null, args.clone()); + return new Parsed(command, cwd, output, null, null, null, null, args.clone()); } String tags = null; String name = null; + String example = null; String retention = null; + boolean absorbName = false; + boolean absorbExample = false; Path project = null; List rest = new ArrayList<>(); for (int index = 1; index < args.length; index++) { @@ -78,10 +82,26 @@ static Parsed parse(String[] args) { } if (token.startsWith("--name=")) { name = token.substring("--name=".length()); + absorbName = true; + absorbExample = false; continue; } if ("--name".equals(token) && index + 1 < args.length) { name = args[++index]; + absorbName = true; + absorbExample = false; + continue; + } + if (token.startsWith("--example=")) { + example = blankToNull(token.substring("--example=".length())); + absorbExample = example != null; + absorbName = false; + continue; + } + if ("--example".equals(token) && index + 1 < args.length) { + example = blankToNull(args[++index]); + absorbExample = example != null; + absorbName = false; continue; } if (token.startsWith("--retention=")) { @@ -97,8 +117,13 @@ static Parsed parse(String[] args) { rest.add(token); continue; } - // Maven exec splits unquoted --name=The failing scenario into extra tokens. - if (name != null && !looksLikeProject(token)) { + // Maven exec splits unquoted --name=The failing scenario and --example=1 2 5 + // into extra tokens. The selector set most recently absorbs them. + if (absorbExample && example != null && !looksLikeProject(token)) { + example = example + " " + token; + continue; + } + if (absorbName && name != null && !looksLikeProject(token)) { name = name + " " + token; continue; } @@ -122,10 +147,20 @@ static Parsed parse(String[] args) { forwarded.add("--name"); forwarded.add(name); } + if (example != null && !example.isBlank()) { + forwarded.add("--example"); + forwarded.add(example); + } forwarded.addAll(rest); - return new Parsed(command, project, null, tags, name, retention, forwarded.toArray(String[]::new)); + return new Parsed( + command, project, null, tags, name, example, retention, forwarded.toArray(String[]::new) + ); } - return new Parsed(command, project, null, tags, name, retention, args.clone()); + return new Parsed(command, project, null, tags, name, example, retention, args.clone()); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; } private static String parseRetention(String value) { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java index 7d9b57cdd..a81e13653 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java @@ -35,7 +35,7 @@ private WorkbenchSessionCommands() { @FunctionalInterface interface DetachedStarter { - Process start(Path project, String tags, String name, Path logFile) throws IOException; + Process start(Path project, String tags, String name, String example, Path logFile) throws IOException; } public static int run(String[] args, PrintStream out, PrintStream err) { @@ -84,7 +84,13 @@ static int run( } } - static Process startControllerSession(Path project, String tags, String name, Path logFile) throws IOException { + static Process startControllerSession( + Path project, + String tags, + String name, + String example, + Path logFile + ) throws IOException { Path controllerJar = PickleballWorkbenchLauncher.extractEmbeddedPayload(project); List forwarded = new ArrayList<>(); forwarded.add("session"); @@ -97,6 +103,10 @@ static Process startControllerSession(Path project, String tags, String name, Pa forwarded.add("--name"); forwarded.add(name); } + if (example != null && !example.isBlank()) { + forwarded.add("--example"); + forwarded.add(example); + } return startDetached(project, PickleballWorkbenchLauncher.command( controllerJar, forwarded.toArray(String[]::new) ), logFile); @@ -131,7 +141,7 @@ private static int sessionStart( Path logFile = PickleballLocalLayout.workbenchStateRoot(project).resolve("session.log"); Process process; try { - process = starter.start(project, parsed.tags(), parsed.name(), logFile); + process = starter.start(project, parsed.tags(), parsed.name(), parsed.example(), logFile); } catch (IOException failure) { throw new IllegalStateException("Could not start Workbench session: " + failure.getMessage(), failure); } @@ -470,6 +480,19 @@ static SessionFlags parse(String[] args) { index++; continue; } + if (token.startsWith("--example=")) { + while (index + 1 < args.length && isSelectorTail(args[index + 1])) { + index++; + } + continue; + } + if ("--example".equals(token) && index + 1 < args.length) { + index++; + while (index + 1 < args.length && isSelectorTail(args[index + 1])) { + index++; + } + continue; + } if (token.startsWith("-")) continue; if (looksLikeProject(token)) continue; words.add(token); @@ -485,6 +508,10 @@ static SessionFlags parse(String[] args) { return new SessionFlags(text, id, ackOnly, wait); } + private static boolean isSelectorTail(String token) { + return token != null && !token.startsWith("-") && !looksLikeProject(token); + } + private static boolean looksLikeProject(String token) { if (token == null || token.isBlank()) return false; if (".".equals(token) || "..".equals(token)) return true; diff --git a/src/main/java/tools/dscode/testengine/DynamicSuiteEngine.java b/src/main/java/tools/dscode/testengine/DynamicSuiteEngine.java index 939ab4259..6d5a69550 100644 --- a/src/main/java/tools/dscode/testengine/DynamicSuiteEngine.java +++ b/src/main/java/tools/dscode/testengine/DynamicSuiteEngine.java @@ -11,6 +11,8 @@ import java.util.Map; import java.util.TreeMap; +import static io.cucumber.core.options.Constants.FILTER_NAME_PROPERTY_NAME; +import static io.cucumber.core.options.Constants.FILTER_TAGS_PROPERTY_NAME; import static tools.dscode.common.reporting.logging.LogForwarder.logError; import static tools.dscode.common.reporting.logging.LogForwarder.logTrace; @@ -45,7 +47,23 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId DynamicEngineDiscoveryRequest wrappedRequest = new DynamicEngineDiscoveryRequest(discoveryRequest, mergedParameters); - return delegate.discover(wrappedRequest, uniqueId); + TestDescriptor discovered = delegate.discover(wrappedRequest, uniqueId); + Map config = materialize(mergedParameters); + ExampleRowFilter.applyToDiscoveredSuite( + discovered, + firstNonBlank(config.get(PKB_props.PKB_EXAMPLE), PKB_props.example()), + firstNonBlank( + config.get(FILTER_TAGS_PROPERTY_NAME), + config.get(PKB_props.PKB_TAGS), + PKB_props.tags() + ), + firstNonBlank( + config.get(FILTER_NAME_PROPERTY_NAME), + config.get(PKB_props.PKB_NAME), + PKB_props.name() + ) + ); + return discovered; } catch (RuntimeException e) { logError("[DynamicSuiteEngine] discover() failed: " + e.getMessage()); e.printStackTrace(System.err); @@ -83,6 +101,18 @@ public void execute(ExecutionRequest request) { } } + private static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + private static Map materialize(ConfigurationParameters parameters) { Map out = new TreeMap<>(); for (String key : parameters.keySet()) { diff --git a/src/main/java/tools/dscode/testengine/ExampleRowFilter.java b/src/main/java/tools/dscode/testengine/ExampleRowFilter.java new file mode 100644 index 000000000..fa377a6d6 --- /dev/null +++ b/src/main/java/tools/dscode/testengine/ExampleRowFilter.java @@ -0,0 +1,384 @@ +package tools.dscode.testengine; + +import io.cucumber.core.gherkin.Feature; +import io.cucumber.core.gherkin.Pickle; +import io.cucumber.core.gherkin.messages.GherkinMessagesFeatureParser; +import io.cucumber.core.runner.util.CucumberQueryUtil; +import io.cucumber.messages.types.Examples; +import io.cucumber.messages.types.TableRow; +import io.cucumber.tagexpressions.Expression; +import io.cucumber.tagexpressions.TagExpressionParser; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.UniqueId; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Selects Examples rows after tag, name, and feature-name filters. + * A normal scenario is table 1, row 1. + */ +public final class ExampleRowFilter { + private static final Pattern INTEGER = Pattern.compile("[1-9][0-9]*"); + private static final Pattern TABLE_ROW = Pattern.compile("([1-9][0-9]*)\\.([1-9][0-9]*)"); + private static final Pattern RANGE = Pattern.compile("([1-9][0-9]*)-([1-9][0-9]*)"); + private static final String FEATURE_SEGMENT = "feature"; + private static final String SCENARIO_SEGMENT = "scenario"; + private static final String EXAMPLE_SEGMENT = "example"; + + private final List clauses; + + private ExampleRowFilter(List clauses) { + this.clauses = List.copyOf(clauses); + } + + public static boolean isInactive(String expression) { + return expression == null || expression.isBlank(); + } + + public static ExampleRowFilter parse(String expression) { + if (isInactive(expression)) { + throw invalid(""); + } + String[] tokens = expression.trim().split("\\s+"); + List clauses = new ArrayList<>(); + for (String token : tokens) { + if (token == null || token.isEmpty()) { + throw invalid(""); + } + clauses.add(parseToken(token)); + } + if (clauses.isEmpty()) { + throw invalid(""); + } + return new ExampleRowFilter(clauses); + } + + public boolean matches(int overall, int table, int row) { + for (Clause clause : clauses) { + if (clause.matches(overall, table, row)) { + return true; + } + } + return false; + } + + public static List filterPickles(String expression, List pickles) { + Objects.requireNonNull(pickles, "pickles"); + if (isInactive(expression)) { + return List.copyOf(pickles); + } + return parse(expression).apply(pickles); + } + + /** + * Drops discovered tests that survived tag and name selection but do not match + * {@code pkb_example}. An empty result fails the run. + */ + public static void applyToDiscoveredSuite( + TestDescriptor root, + String example, + String tags, + String name + ) { + Objects.requireNonNull(root, "root"); + if (isInactive(example)) { + return; + } + ExampleRowFilter filter = parse(example); + List tests = new ArrayList<>(); + collectTests(root, tests); + Map features = new HashMap<>(); + List selected = new ArrayList<>(); + List coordinates = new ArrayList<>(); + for (TestDescriptor test : tests) { + if (!looksLikeScenario(test)) { + continue; + } + Located located = locate(test, features); + if (located == null) { + throw new IllegalArgumentException( + "pkb_example could not locate a scenario row for " + test.getUniqueId() + ); + } + if (!passesPriorFilters(located.pickle(), tags, name)) { + continue; + } + selected.add(test); + coordinates.add(located.coordinates()); + } + Set kept = new HashSet<>(); + Set seen = new HashSet<>(); + for (int index = 0; index < selected.size(); index++) { + Coordinates row = coordinates.get(index); + if (row == null || !filter.matches(row.overall(), row.table(), row.row())) { + continue; + } + if (seen.add(row.identity())) { + kept.add(selected.get(index)); + } + } + if (kept.isEmpty()) { + throw noScenariosMatched(tags, name, example); + } + for (TestDescriptor test : selected) { + if (!kept.contains(test)) { + test.removeFromHierarchy(); + } + } + } + + public static IllegalArgumentException noScenariosMatched(String tags, String name, String example) { + StringBuilder message = new StringBuilder("No scenarios matched the provided filters:"); + appendDetail(message, "tags", tags); + appendDetail(message, "name", name); + appendDetail(message, "example", example); + return new IllegalArgumentException(message.toString()); + } + + private List apply(List pickles) { + List kept = new ArrayList<>(); + Set seen = new HashSet<>(); + for (Pickle pickle : pickles) { + Coordinates row = coordinatesOf(pickle); + if (row == null || !matches(row.overall(), row.table(), row.row())) { + continue; + } + if (seen.add(row.identity())) { + kept.add(pickle); + } + } + return List.copyOf(kept); + } + + static Coordinates coordinatesOf(Pickle pickle) { + CucumberQueryUtil.GherkinView view = CucumberQueryUtil.describe(pickle); + String uri = pickle.getUri() == null ? "" : pickle.getUri().toString(); + String scenarioId = view.scenario.getId(); + if (!view.scenarioOutline) { + return new Coordinates(uri, scenarioId, 1, 1, 1); + } + if (view.exampleRow == null) { + return null; + } + String rowId = view.exampleRow.getId(); + int tableNumber = 0; + int overall = 0; + for (Examples examples : view.scenario.getExamples()) { + tableNumber++; + List body = examples.getTableBody(); + if (body == null) { + continue; + } + int rowInTable = 0; + for (TableRow candidate : body) { + rowInTable++; + overall++; + if (candidate.getId() != null && candidate.getId().equals(rowId)) { + return new Coordinates(uri, scenarioId, overall, tableNumber, rowInTable); + } + } + } + return null; + } + + private static boolean passesPriorFilters(Pickle pickle, String tags, String name) { + if (tags != null && !tags.isBlank()) { + Expression expression = TagExpressionParser.parse(tags.trim()); + if (!expression.evaluate(pickle.getTags())) { + return false; + } + } + if (name != null && !name.isBlank()) { + if (!Pattern.compile(name.trim()).matcher(pickle.getName()).matches()) { + return false; + } + } + return true; + } + + private static void collectTests(TestDescriptor node, List tests) { + if (node.getType() == TestDescriptor.Type.TEST) { + tests.add(node); + } + for (TestDescriptor child : node.getChildren()) { + collectTests(child, tests); + } + } + + private static boolean looksLikeScenario(TestDescriptor test) { + return segment(test.getUniqueId(), FEATURE_SEGMENT) != null + && (segment(test.getUniqueId(), EXAMPLE_SEGMENT) != null + || segment(test.getUniqueId(), SCENARIO_SEGMENT) != null); + } + + private static Located locate(TestDescriptor test, Map features) { + String featureValue = segment(test.getUniqueId(), FEATURE_SEGMENT); + String lineText = segment(test.getUniqueId(), EXAMPLE_SEGMENT); + if (lineText == null) { + lineText = segment(test.getUniqueId(), SCENARIO_SEGMENT); + } + if (featureValue == null || lineText == null) { + return null; + } + int line; + try { + line = Integer.parseInt(lineText); + } catch (NumberFormatException exception) { + return null; + } + Feature feature = features.computeIfAbsent(featureValue, ExampleRowFilter::loadFeature); + if (feature == null) { + return null; + } + for (Pickle pickle : feature.getPickles()) { + if (pickle.getLocation() != null && pickle.getLocation().getLine() == line) { + return new Located(pickle, coordinatesOf(pickle)); + } + } + return null; + } + + private static Feature loadFeature(String featureValue) { + URI uri = URI.create(featureValue); + GherkinMessagesFeatureParser parser = new GherkinMessagesFeatureParser(); + try (InputStream input = open(uri)) { + Optional parsed = parser.parse(uri, input, UUID::randomUUID); + return parsed.orElse(null); + } catch (IOException exception) { + throw new IllegalArgumentException( + "pkb_example could not read feature " + featureValue, + exception + ); + } + } + + private static InputStream open(URI uri) throws IOException { + String external = uri.toString(); + if (external.toLowerCase(Locale.ROOT).startsWith("classpath:")) { + String resource = external.substring("classpath:".length()); + while (resource.startsWith("/")) { + resource = resource.substring(1); + } + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + if (loader == null) { + loader = ExampleRowFilter.class.getClassLoader(); + } + InputStream input = loader.getResourceAsStream(resource); + if (input == null) { + throw new IOException("Classpath feature was not found: " + external); + } + return input; + } + try { + return Files.newInputStream(Path.of(uri)); + } catch (RuntimeException | IOException first) { + return uri.toURL().openStream(); + } + } + + private static String segment(UniqueId uniqueId, String type) { + String value = null; + for (UniqueId.Segment segment : uniqueId.getSegments()) { + if (type.equals(segment.getType())) { + value = segment.getValue(); + } + } + return value; + } + + private static Clause parseToken(String token) { + if (INTEGER.matcher(token).matches()) { + return new Overall(parsePositive(token)); + } + Matcher table = TABLE_ROW.matcher(token); + if (table.matches()) { + return new TableRowClause(parsePositive(table.group(1)), parsePositive(table.group(2))); + } + Matcher range = RANGE.matcher(token); + if (range.matches()) { + int low = parsePositive(range.group(1)); + int high = parsePositive(range.group(2)); + if (low > high) { + throw invalid(token); + } + return new RangeClause(low, high); + } + throw invalid(token); + } + + private static int parsePositive(String token) { + try { + int value = Integer.parseInt(token); + if (value <= 0) { + throw invalid(token); + } + return value; + } catch (NumberFormatException exception) { + throw invalid(token); + } + } + + private static IllegalArgumentException invalid(String token) { + return new IllegalArgumentException( + "Invalid pkb_example token '" + token + "'. " + + "Expected a positive integer, table.row, or low-high range of positive integers " + + "with no leading zeros." + ); + } + + private static void appendDetail(StringBuilder message, String label, String value) { + if (value != null && !value.isBlank()) { + message.append(' ').append(label).append("=[").append(value.trim()).append(']'); + } + } + + private interface Clause { + boolean matches(int overall, int table, int row); + } + + private record Overall(int index) implements Clause { + @Override + public boolean matches(int overall, int table, int row) { + return overall == index; + } + } + + private record TableRowClause(int tableNumber, int rowInTable) implements Clause { + @Override + public boolean matches(int overall, int table, int row) { + return table == tableNumber && row == rowInTable; + } + } + + private record RangeClause(int low, int high) implements Clause { + @Override + public boolean matches(int overall, int table, int row) { + return overall >= low && overall <= high; + } + } + + record Coordinates(String uri, String scenarioId, int overall, int table, int row) { + String identity() { + return uri + "|" + scenarioId + "|" + overall; + } + } + + private record Located(Pickle pickle, Coordinates coordinates) { + } +} diff --git a/src/main/java/tools/dscode/testengine/PKB_props.java b/src/main/java/tools/dscode/testengine/PKB_props.java index a04510f41..b8e9f6a88 100644 --- a/src/main/java/tools/dscode/testengine/PKB_props.java +++ b/src/main/java/tools/dscode/testengine/PKB_props.java @@ -19,6 +19,7 @@ public final class PKB_props { public static final String PKB_CONFIG_PATH = PKB_PREFIX + "configpath"; public static final String PKB_TAGS = PKB_PREFIX + "tags"; public static final String PKB_NAME = PKB_PREFIX + "name"; + public static final String PKB_EXAMPLE = PKB_PREFIX + "example"; public static final String PKB_ORDER = PKB_PREFIX + "order"; public static final String PKB_LIMIT = PKB_PREFIX + "limit"; public static final String PKB_PROFILE = PKB_PREFIX + "profile"; @@ -145,6 +146,8 @@ public static boolean isRunProfileMemberKey(String key) { public static void tags(String tagExpression) { put(PKB_TAGS, tagExpression); } public static String name() { return get(PKB_NAME); } public static void name(String nameRegex) { put(PKB_NAME, nameRegex); } + public static String example() { return get(PKB_EXAMPLE); } + public static void example(String exampleSelector) { put(PKB_EXAMPLE, exampleSelector); } public static String plugins() { return get(PKB_PLUGINS); } public static void plugins(String pluginConfig) { put(PKB_PLUGINS, pluginConfig); } public static String profile() { return get(PKB_PROFILE); } diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index 996555d5c..323228902 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -16,8 +16,8 @@ mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.ds Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, and `stop` — change `exec.args` only. -1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. -2. **Confirm** — `-Dexec.args=confirm --tags=... --name=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. +1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. +2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name/example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. 3. **Live debug** — `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each Maven exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. @@ -33,7 +33,7 @@ After Discover has found the failing scenario, `isolate` starts a headless Workb 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. 3. `-Dexec.args=status` or `status `; `-Dexec.args=events`. 4. `-Dexec.args=stop` (or `kill`) when finished. -5. Confirm with Workbench `confirm` (`-Dexec.args=confirm --tags=... --name=...`). Read the pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` when those tools already exist. +5. Confirm with Workbench `confirm` (`-Dexec.args=confirm --tags=... --name=... --example=...`). Read the pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` when those tools already exist. 6. Emit the human handoff with `workbench_investigation_emit` or `DiagnosticCli emit-investigation`. In chat print the six-line bottom-line block, then the `report.html` path. If a UI session is attached, at most two `wb://` links may follow. If no UI, omit `wb://`. `execute-step` / `workbench_execute_step` returns a structured `SUCCESS` / `FAILED` / `UNAVAILABLE` result. A FAILED Gherkin hypothesis does not end the worker and does not fail the paused scenario. `workbench_step_resolve` maps one Gherkin step to its Java definition (or `DYNAMIC` / `OVERRIDE` / `UNMATCHED`) without executing it. Page events with `afterSequence` and a small `limit` (default 100, max 500). Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). Worker restart without rebuild already exists (`workbench_worker_restart`). Step Overrides compile worker-side (`workbench_step_override_compile`). @@ -156,7 +156,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -Use the narrowest `pkb_tags` / `pkb_name` that isolate the failure. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. +Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: @@ -293,9 +293,9 @@ The agent-facing name is Workbench. From a Maven consumer where Pickleball is on ```text mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" "-Dexec.args=hint" -"-Dexec.args=discover [--tags ] [--name ]" -"-Dexec.args=confirm [--tags ] [--name ]" -"-Dexec.args=isolate" +"-Dexec.args=discover [--tags ] [--name ] [--example ]" +"-Dexec.args=confirm [--tags ] [--name ] [--example ]" +"-Dexec.args=isolate [--example ]" "-Dexec.args=execute-step --text='Given stay'" "-Dexec.args=status" "-Dexec.args=events" 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 f1bff22e3..b9426c14a 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 @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`; public CLI `--tags` / `--name` / `--retention=all\|failed\|none` on discover/confirm/hint (Discover default `failed`) | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -18,7 +18,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index ae2cabbf0..bbf44cbe5 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 @@ -387,6 +387,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/testengine/DynamicSuiteConfigUtils.java` - `src/main/java/tools/dscode/testengine/DynamicSuiteEngine.java` - `src/main/java/tools/dscode/testengine/EngineFilterBootstrap.java` +- `src/main/java/tools/dscode/testengine/ExampleRowFilter.java` - `src/main/java/tools/dscode/testengine/MergedConfigurationParameters.java` - `src/main/java/tools/dscode/testengine/PickleballLauncherSessionListener.java` - `src/main/java/tools/dscode/testengine/PickleballProfiles.java` @@ -416,6 +417,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java` - `src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` +- `src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java` ## Control API module @@ -666,6 +668,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/tools/dscode/coredefinitions/ModularScenariosChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorChecks.java` +- `maven-consumer-project/src/test/java/tools/dscode/testengine/ExampleRowFilterChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/testengine/PkbPropertyValueNormalizerChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/testengine/ProfileConfigurationChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/testengine/RunVarOverrideChecks.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index 800cfa71e..3bb897e95 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -440,7 +440,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -plus the narrowest useful `pkb_tags` / `pkb_name`. Multi-scenario Discover/Confirm use that high parallelism. Live isolate stays one paused scenario on a headless CLI session started with Maven-exec `isolate`. +plus the narrowest useful `pkb_tags` / `pkb_name`. An agent can also pass `pkb_example`, or `confirm` / `isolate --example=...`, to run one or more Examples rows without the GUI. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. Live isolate stays one paused scenario (`pkb_parallel=1`) on a headless CLI session started with Maven-exec `isolate`. After Discover, inspect `pkb_run_profile` from `run-catalog.json`, `run-index.json`, or `summary.json`. Confirm and live isolate replay that retained profile through `pkb_runvars` (LastDiscoverSnapshot) unless the snapshot is marked sealed, in which case the next worker launch uses `-Dpkb_overriderunvars=`. If there is no prior Discover snapshot, Workbench says so; it does not silently re-resolve from project defaults. The Workbench sealed-RunVars panel, if unused, changes no behavior; Apply writes a sealed snapshot for the **next** launch and does not mutate an in-flight worker. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index c1dc966ee..1dd6b68d6 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -358,6 +358,7 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_configpath` | `configs` | source behind the stable `configs` mapping | | `pkb_tags` | `@smoke and not @slow` | Cucumber tag expression | | `pkb_name` | `Checkout.*` | scenario-name expression | +| `pkb_example` | `1 2 5 3.4 7-11` | Examples-row filter applied after tags and name. Not a tag | | `pkb_environment` | `QA` | project environment label | | `pkb_browser` | `chrome` | browser configuration name looked up under the `configs` mapping (`CHROME_HEADLESS` uses the consumer yaml when present, otherwise Pickleball's bundled headless Chrome) | | `pkb_profile` | `qa,browser_firefox` | selected named profile(s) | @@ -374,6 +375,24 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_platformlog` | `default`, `default+git`, `none`, etc. | platform/caller log stamps | | `pkb_gitsnapshot` | `metadata`, `diff`, `none` | diagnostic Git/source provenance | +## Example row filter + +`pkb_example` keeps specific Examples rows from scenarios that `pkb_tags`, `pkb_name`, and, on a component or `listPickles` scan, `pkb_featurename` have already selected. Where those filters select scenarios, `pkb_example` runs after them. It is not a tag and it does not extend Cucumber tag expressions (`and` / `or` / `not`). It has no `cucumber.filter` alias. `pkb_name` still matches the Scenario or Scenario Outline title and still selects every row until `pkb_example` runs. A normal Maven run applies the same filter after Cucumber's tag and name filters. It does not start honoring `pkb_featurename` on that engine path. + +Absent or blank `pkb_example` adds no filter. + +Tokens are separated by whitespace: + +- An integer `N` is overall value row `N` of that scenario, counted straight through every Examples table. The header is not a value row. `5` with two tables of 3 value rows is table 2, row 2. +- `A.B` is table `A`, value row `B` inside that table. `3.5` is the fifth value row of the third Examples table. +- `Low-High` is an inclusive range of overall indexes only. `7-11` is overall rows 7 through 11. A range is never a table range. + +A list mixes these. `1 2 5 3.4 7-11` keeps overall rows 1, 2, and 5, plus table 3 row 4, plus overall rows 7 through 11. Matching rows are emitted in source order. A row named more than once runs once. + +Each selected scenario is filtered on its own. A normal Scenario is one implicit Examples row, so `1` and `1.1` keep it and any other integer drops it. An outline with no matching row contributes nothing. The run does not wrap, borrow a row from another table, or fail only because one outline was shorter than the index. If the filter leaves zero pickles, the run fails the same way an empty tag or name selection fails. Invalid syntax rejects the run. That includes `0`, negatives, leading zeros, `3.`, `.5`, `2.0`, `1.2.3`, `5-3`, `5-`, `-5`, and a range used as a table selector. + +`pkb_order` and `pkb_limit` still apply after this filter. + Other existing `pkb_*` RunVars retain their previous behavior unless specifically documented otherwise. ## Conservative `pkb_parallel` @@ -418,6 +437,8 @@ Pickleball synchronizes its main selection aliases with Cucumber properties, inc - `pkb_tags` ↔ Cucumber tag filter; - `pkb_name` ↔ Cucumber name filter. +`pkb_example` is not one of those aliases. Put it in `pkb_runvars` (or pass `--example` to Discover, Confirm, and isolate). It is not a Cucumber CLI selector. + Normal command-line Cucumber projection remains supported. When controlled direct RunVars are active, projected Cucumber CLI selection values do not mutate the controlled RunVar set; put intended values in `pkb_runvars`. ## ReportPortal aliases and protected values diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index 996555d5c..323228902 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -16,8 +16,8 @@ mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.ds Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, and `stop` — change `exec.args` only. -1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. -2. **Confirm** — `-Dexec.args=confirm --tags=... --name=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. +1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. +2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name/example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. 3. **Live debug** — `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each Maven exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. @@ -33,7 +33,7 @@ After Discover has found the failing scenario, `isolate` starts a headless Workb 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. 3. `-Dexec.args=status` or `status `; `-Dexec.args=events`. 4. `-Dexec.args=stop` (or `kill`) when finished. -5. Confirm with Workbench `confirm` (`-Dexec.args=confirm --tags=... --name=...`). Read the pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` when those tools already exist. +5. Confirm with Workbench `confirm` (`-Dexec.args=confirm --tags=... --name=... --example=...`). Read the pack with `workbench_diagnostic_catalog`, `workbench_diagnostic_run`, and `workbench_diagnostic_summary` when those tools already exist. 6. Emit the human handoff with `workbench_investigation_emit` or `DiagnosticCli emit-investigation`. In chat print the six-line bottom-line block, then the `report.html` path. If a UI session is attached, at most two `wb://` links may follow. If no UI, omit `wb://`. `execute-step` / `workbench_execute_step` returns a structured `SUCCESS` / `FAILED` / `UNAVAILABLE` result. A FAILED Gherkin hypothesis does not end the worker and does not fail the paused scenario. `workbench_step_resolve` maps one Gherkin step to its Java definition (or `DYNAMIC` / `OVERRIDE` / `UNMATCHED`) without executing it. Page events with `afterSequence` and a small `limit` (default 100, max 500). Live buffer edits do not require `workbench_sync` and do not write the original `.feature` until explicit Save (`workbench_request_save`). Worker restart without rebuild already exists (`workbench_worker_restart`). Step Overrides compile worker-side (`workbench_step_override_compile`). @@ -156,7 +156,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -Use the narrowest `pkb_tags` / `pkb_name` that isolate the failure. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. +Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: @@ -293,9 +293,9 @@ The agent-facing name is Workbench. From a Maven consumer where Pickleball is on ```text mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" "-Dexec.args=hint" -"-Dexec.args=discover [--tags ] [--name ]" -"-Dexec.args=confirm [--tags ] [--name ]" -"-Dexec.args=isolate" +"-Dexec.args=discover [--tags ] [--name ] [--example ]" +"-Dexec.args=confirm [--tags ] [--name ] [--example ]" +"-Dexec.args=isolate [--example ]" "-Dexec.args=execute-step --text='Given stay'" "-Dexec.args=status" "-Dexec.args=events" diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index cdc74b8a8..2da793751 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -152,12 +152,15 @@ Controller/protocol migration checks must remain focused: use `@control-bridge` ```bash mvn test -Dpkb_tags="@forms and @state-assertions" mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" +mvn test "-Dpkb_example=1 2 5 3.4 7-11" ``` +`pkb_example` runs after `pkb_tags` and `pkb_name`. It keeps Examples rows. It is not a tag. A normal Scenario counts as row 1. The same selector is `--example` on Workbench `discover`, `confirm`, and `isolate`. + Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a bounded confirmation should not reuse those defaults. Use Workbench `confirm`, or a separate `pkb_runvars` command that honors the browser ladder: ```bash -PickleballWorkbenchLauncher confirm --tags=@the-failing-tag --name='The failing scenario' +PickleballWorkbenchLauncher confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11' ``` After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. @@ -239,7 +242,7 @@ Do not recursively ingest an entire run. ```text mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=hint" "-Dexec.args=discover" -"-Dexec.args=confirm --tags=@the-failing-tag" +"-Dexec.args=confirm --tags=@the-failing-tag --example=1.1" DiagnosticCli compare-runs [output-json] DiagnosticCli compare-fingerprints [output-json] DiagnosticCli emit-investigation 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 0fb5d4151..0131b7178 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 @@ -59,13 +59,16 @@ mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ "-Dexec.args=discover --tags=@smoke" "-Dexec.args=discover --tags=@smoke --retention=all" "-Dexec.args=confirm --tags=@smoke --name='The failing scenario'" -"-Dexec.args=isolate" +"-Dexec.args=confirm --tags=@smoke --example='1 2 5 3.4 7-11'" +"-Dexec.args=isolate --example=1.1" "-Dexec.args=execute-step --text='Given stay'" ``` -Same launcher; only change `-Dexec.args`. `isolate` starts a detached headless session; later execs are one-shot HTTP clients. +Same launcher; only change `-Dexec.args`. `isolate` starts a detached headless session; later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. -`hint`, `discover`, and `confirm` accept `--tags` / `--name` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. +`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. Play still picks one visible row in the Workbench player. Headless selection is `pkb_example`. Do not start the GUI. + +`hint`, `discover`, and `confirm` accept `--tags` / `--name` / `--example` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This retention overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` print a dry-run resolve preview (`PKB_props.resolveRunVars`) that does not start tests. Optional sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map including the six context keys → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. When a LastDiscoverSnapshot is marked sealed, the next worker launch uses `-Dpkb_overriderunvars=`, never `-Dpkb_run_profile=`. Unused, sealed input changes no behavior. diff --git a/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java b/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java index 0ae14728e..46aff928e 100644 --- a/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java +++ b/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java @@ -72,6 +72,32 @@ void isolateReplaysRetainedProfileWithoutReinheritingDefaults() { assertFalse(isolate.contains("@all")); } + @Test + void discoverConfirmAndIsolateForwardExampleIntoRunVars() { + LinkedHashMap retained = new LinkedHashMap<>(); + retained.put(PKB_props.PKB_BROWSER, "CHROME_HEADLESS"); + retained.put(PKB_props.PKB_PARALLEL, "12"); + retained.put(PKB_props.PKB_GLUE, "com.example.pickleball"); + + String discover = AgentDiscoverPlanner.discover( + tempDir, "@smoke", null, null, "1 2 5 3.4 7-11" + ).runVars(); + String confirm = AgentDiscoverPlanner.confirmRunVars( + retained, "@one", null, null, "1 2 5 3.4 7-11" + ); + String isolate = AgentDiscoverPlanner.isolateRunVars( + retained, "@failing", "Broken scenario", null, "1 2 5 3.4 7-11" + ); + + assertTrue(discover.contains("pkb_example=1 2 5 3.4 7-11")); + assertTrue(discover.contains("pkb_tags=@smoke")); + assertTrue(confirm.contains("pkb_example=1 2 5 3.4 7-11")); + assertTrue(confirm.contains("pkb_parallel=12")); + assertTrue(isolate.contains("pkb_example=1 2 5 3.4 7-11")); + assertTrue(isolate.contains("pkb_parallel=1")); + assertFalse(isolate.contains("pkb_parallel=12")); + } + @Test void isolateKeepsSnapshotRetentionUnlessOverlaidAndStillForcesParallelOne() { LinkedHashMap retained = new LinkedHashMap<>(); diff --git a/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java index 8605399ad..ee6362c6b 100644 --- a/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java +++ b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java @@ -196,6 +196,12 @@ void argumentNormalizationAndSessionCommandsAreUnchanged() { assertEquals("--name", isolateName[2]); assertEquals("The failing scenario", isolateName[3]); + String[] isolateExample = PickleballWorkbenchLauncher.normalizedArguments( + new String[]{"isolate", "--example=1", "2", "5", "3.4", "7-11"} + ); + assertEquals("--example", isolateExample[2]); + assertEquals("1 2 5 3.4 7-11", isolateExample[3]); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("isolate")); assertTrue(WorkbenchCommandLine.isSessionClientCommand("session-start")); assertTrue(WorkbenchCommandLine.isSessionClientCommand("execute-step")); diff --git a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java index 5ea020474..40e801292 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java @@ -46,6 +46,46 @@ void retentionIsNotAbsorbedIntoMavenExecNameJoining() { assertEquals("all", parsed.retention()); } + @Test + void exampleJoinsSplitTokensWithoutStealingTheName() { + WorkbenchCommandLine.Parsed parsed = WorkbenchCommandLine.parse( + new String[]{"discover", "--name=The", "failing", "scenario", "--example=1", "2", "5", "3.4", "7-11"} + ); + assertEquals("The failing scenario", parsed.name()); + assertEquals("1 2 5 3.4 7-11", parsed.example()); + } + + @Test + void nameDoesNotAbsorbTokensAfterExample() { + WorkbenchCommandLine.Parsed parsed = WorkbenchCommandLine.parse( + new String[]{"confirm", "--example=1", "2", "--name=The", "failing", "scenario"} + ); + assertEquals("1 2", parsed.example()); + assertEquals("The failing scenario", parsed.name()); + } + + @Test + void blankExampleIsNotForwarded() { + WorkbenchCommandLine.Parsed parsed = WorkbenchCommandLine.parse( + new String[]{"isolate", "--example="} + ); + assertNull(parsed.example()); + assertTrue(java.util.Arrays.stream(parsed.forwarded()).noneMatch("--example"::equals)); + } + + @Test + void isolateForwardsExampleAfterName() { + WorkbenchCommandLine.Parsed parsed = WorkbenchCommandLine.parse( + new String[]{"isolate", "--tags=@one", "--name=The", "failing", "scenario", "--example=1", "2"} + ); + assertEquals("--tags", parsed.forwarded()[2]); + assertEquals("@one", parsed.forwarded()[3]); + assertEquals("--name", parsed.forwarded()[4]); + assertEquals("The failing scenario", parsed.forwarded()[5]); + assertEquals("--example", parsed.forwarded()[6]); + assertEquals("1 2", parsed.forwarded()[7]); + } + @Test void omittedRetentionStaysNull() { WorkbenchCommandLine.Parsed parsed = WorkbenchCommandLine.parse( diff --git a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java index 052c6cd14..69fb9f46d 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java @@ -35,7 +35,7 @@ void isolateDoesNotBlockOnStdinWhenSessionAlreadyHealthy() throws Exception { AtomicBoolean started = new AtomicBoolean(); Output output = run( new String[]{"isolate", project.toString()}, - (proj, tags, name, log) -> { + (proj, tags, name, example, log) -> { started.set(true); throw new AssertionError("should not start a second session"); } @@ -53,8 +53,9 @@ void sessionStartDetachesAndDoesNotWaitForTheControllerProcess() throws Exceptio AtomicReference child = new AtomicReference<>(); try { Output output = run( - new String[]{"session-start", project.toString()}, - (proj, tags, name, log) -> { + new String[]{"session-start", project.toString(), "--example=1", "2", "5"}, + (proj, tags, name, example, log) -> { + assertEquals("1 2 5", example); FakeSession session = FakeSession.start(proj); started.set(session); Process process = startLongLivedChild(); @@ -79,7 +80,7 @@ void executeStepAckOnlyExitsWithoutWaitingForDone() throws Exception { try (FakeSession ignored = FakeSession.start(project)) { Output output = run( new String[]{"execute-step", project.toString(), "--text=Given stay", "--ack-only"}, - (proj, tags, name, log) -> { + (proj, tags, name, example, log) -> { throw new AssertionError("execute-step must not start a session process"); } ); diff --git a/src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java b/src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java new file mode 100644 index 000000000..4abb816a2 --- /dev/null +++ b/src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java @@ -0,0 +1,426 @@ +package tools.dscode.testengine; + +import io.cucumber.core.gherkin.Feature; +import io.cucumber.core.gherkin.Pickle; +import io.cucumber.core.gherkin.messages.GherkinMessagesFeatureParser; +import io.cucumber.core.runner.modularexecutions.CucumberScanUtil; +import io.cucumber.core.runner.util.CucumberQueryUtil; +import io.cucumber.messages.types.TableRow; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.UniqueId; +import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +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; +import static tools.dscode.testengine.PKB_props.PKB_EXAMPLE; +import static tools.dscode.testengine.PKB_props.PKB_FEATURES; +import static tools.dscode.testengine.PKB_props.PKB_FEATURE_NAME; +import static tools.dscode.testengine.PKB_props.PKB_LIMIT; +import static tools.dscode.testengine.PKB_props.PKB_NAME; +import static tools.dscode.testengine.PKB_props.PKB_TAGS; + +class ExampleRowFilterTest { + private static final String FEATURE = """ + Feature: Example filter + + Scenario: Plain + Given value plain + + Scenario Outline: First outline + Given value + Examples: + | v | + | a | + | b | + | c | + Examples: + | v | + | d | + | e | + | f | + + Scenario Outline: Short outline + Given value + Examples: + | v | + | a | + | b | + | c | + Examples: + | v | + | d | + | e | + | f | + + Scenario Outline: Long outline + Given value + Examples: + | v | + | a | + | b | + | c | + Examples: + | v | + | d | + | e | + | f | + Examples: + | v | + | g | + | h | + | i | + | j | + | k | + + Scenario Outline: Tagged outline + Given value + @skip + Examples: + | v | + | a | + | b | + Examples: + | v | + | c | + """; + + private static final String OTHER = """ + Feature: Other + + Scenario Outline: Other outline + Given value + Examples: + | v | + | a | + | b | + | c | + | d | + | e | + | f | + | g | + """; + + @TempDir + Path tempDir; + + private Path featureFile; + private Path otherFile; + + @BeforeEach + void writeFeatures() throws IOException { + CucumberScanUtil.clearCache(); + featureFile = tempDir.resolve("example-filter.feature"); + otherFile = tempDir.resolve("other.feature"); + Files.writeString(featureFile, FEATURE); + Files.writeString(otherFile, OTHER); + } + + @Test + void absentAndBlankExampleAddNoFilter() { + assertTrue(CucumberScanUtil.listPickles(Map.of(PKB_FEATURES, featureFile.toString())).isEmpty()); + + List selected = named(scan(featureFile.toString(), null, null, ".*", null)); + List blank = named(scan(featureFile.toString(), " ", null, ".*", null)); + assertEquals(selected, blank); + assertTrue(selected.contains("Plain:plain")); + assertTrue(selected.contains("Long outline:k")); + } + + @Test + void integerFiveIsTableTwoRowTwoWhenEachTableHasThreeRows() { + List rows = scan(featureFile.toString(), "5", null, "First outline", null); + assertEquals(List.of("First outline:e"), named(rows)); + ExampleRowFilter.Coordinates coordinates = ExampleRowFilter.coordinatesOf(rows.getFirst()); + assertEquals(5, coordinates.overall()); + assertEquals(2, coordinates.table()); + assertEquals(2, coordinates.row()); + } + + @Test + void tableRowSelectorKeepsNormalScenarioOnlyForOneAndOnePointOne() { + assertEquals(List.of("Plain:plain"), named(scan(featureFile.toString(), "1", null, "Plain", null))); + assertEquals(List.of("Plain:plain"), named(scan(featureFile.toString(), "1.1", null, "Plain", null))); + assertEquals(List.of("First outline:b"), named(scan(featureFile.toString(), "1.2", null, "Plain|First outline", null))); + + IllegalArgumentException dropped = assertThrows( + IllegalArgumentException.class, + () -> scan(featureFile.toString(), "2", null, "Plain", null) + ); + assertTrue(dropped.getMessage().startsWith("No scenarios matched the provided filters:")); + assertTrue(dropped.getMessage().contains("example=[2]")); + } + + @Test + void listKeepsSourceOrderAndRunsADuplicateRowOnce() { + assertEquals( + List.of("a", "b", "e", "g", "h", "i", "j", "k"), + cells(scan(featureFile.toString(), "1 2 5 3.4 7-11", null, "Long outline", null)) + ); + assertEquals( + List.of("a", "e"), + cells(scan(featureFile.toString(), "5 1 5 2.2", null, "Long outline", null)) + ); + } + + @Test + void eachSelectedOutlineIsFilteredTheSameWay() { + assertEquals( + List.of("First outline:e", "Short outline:e", "Long outline:e"), + named(scan(featureFile.toString(), "5", null, null, null)) + ); + } + + @Test + void aShortOutlineContributesNothingWhileAnotherMatchDoesNotFail() { + List shortOnly = scan(featureFile.toString(), null, null, "Short outline", null); + assertTrue(ExampleRowFilter.filterPickles("7", shortOnly).isEmpty()); + assertEquals( + List.of("Long outline:g"), + named(scan(featureFile.toString(), "7", null, "Short outline|Long outline", null)) + ); + } + + @Test + void zeroMatchesFailsLikeAnEmptySelection() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> scan(featureFile.toString(), "99", null, ".*", null) + ); + assertTrue(failure.getMessage().startsWith("No scenarios matched the provided filters:")); + assertTrue(failure.getMessage().contains("example=[99]")); + } + + @Test + void invalidTokensFailAndAreNotSkipped() { + for (String token : List.of("0", "5-3", "2.0", "3.", "1.2.3", "01", "5-", "-5", "+5", "1-2.3", "1.2-3")) { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> ExampleRowFilter.parse(token), + token + ); + assertTrue(failure.getMessage().contains("pkb_example"), token); + assertTrue(failure.getMessage().contains("'" + token + "'"), failure.getMessage()); + assertFalse(failure.getMessage().startsWith("No scenarios matched"), token); + } + IllegalArgumentException partial = assertThrows( + IllegalArgumentException.class, + () -> scan(featureFile.toString(), "1 2 0", null, "Long outline", null) + ); + assertTrue(partial.getMessage().contains("'0'")); + assertFalse(partial.getMessage().startsWith("No scenarios matched")); + + IllegalArgumentException spacedRange = assertThrows( + IllegalArgumentException.class, + () -> ExampleRowFilter.parse("7 - 11") + ); + assertTrue(spacedRange.getMessage().contains("'-'")); + } + + @Test + void tagExcludedRowsStillOccupyTheirSourceIndex() { + IllegalArgumentException renamed = assertThrows( + IllegalArgumentException.class, + () -> scan(featureFile.toString(), "1", "not @skip", "Tagged outline", null) + ); + assertTrue(renamed.getMessage().startsWith("No scenarios matched the provided filters:")); + assertEquals( + List.of("Tagged outline:c"), + named(scan(featureFile.toString(), "3", "not @skip", "Tagged outline", null)) + ); + assertEquals( + List.of("Tagged outline:c"), + named(scan(featureFile.toString(), "2.1", "not @skip", "Tagged outline", null)) + ); + } + + @Test + void featureNameRunsBeforeTheExampleFilter() { + String both = featureFile + "," + otherFile; + assertEquals( + List.of("Long outline:g"), + named(scan(both, "7", null, null, "Example filter")) + ); + assertEquals( + List.of("Other outline:g"), + named(scan(both, "7", null, null, "Other")) + ); + } + + @Test + void exampleOnlyScanKeepsRowOneOfEachScenario() { + assertEquals( + List.of( + "Plain:plain", + "First outline:a", + "Short outline:a", + "Long outline:a", + "Tagged outline:a" + ), + named(scan(featureFile.toString(), "1", null, null, null)) + ); + } + + @Test + void orderLimitStillAppliesAfterTheExampleFilter() { + Map props = new HashMap<>(); + props.put(PKB_FEATURES, featureFile.toString()); + props.put(PKB_NAME, "Long outline"); + props.put(PKB_EXAMPLE, "1 2 3"); + props.put(PKB_LIMIT, "1"); + assertEquals(List.of("a"), cells(CucumberScanUtil.listPickles(props))); + } + + @Test + void discoveryDropsSelectedRowsThatMissTheExample() throws IOException { + Feature feature = readFeature(featureFile); + TestDescriptor root = suite(feature); + ExampleRowFilter.applyToDiscoveredSuite(root, "5", null, null); + Map counts = counts(root); + assertEquals(3, counts.getOrDefault("e", 0)); + assertEquals(0, counts.getOrDefault("plain", 0)); + assertEquals(0, counts.getOrDefault("a", 0)); + assertEquals(3, root.getChildren().size()); + } + + @Test + void discoveryLeavesTagMissesInTheTree() throws IOException { + Feature feature = readFeature(featureFile); + TestDescriptor root = suite(feature); + ExampleRowFilter.applyToDiscoveredSuite(root, "3", "not @skip", null); + Map counts = counts(root); + assertEquals(1, counts.getOrDefault("a", 0)); + assertEquals(1, counts.getOrDefault("b", 0)); + assertEquals(4, counts.getOrDefault("c", 0)); + assertEquals(0, counts.getOrDefault("plain", 0)); + } + + @Test + void discoveryEmptyAndInvalidExampleDoNotRemoveRows() throws IOException { + Feature feature = readFeature(featureFile); + TestDescriptor root = suite(feature); + int before = root.getChildren().size(); + ExampleRowFilter.applyToDiscoveredSuite(root, " ", null, null); + assertEquals(before, root.getChildren().size()); + + TestDescriptor empty = suite(feature); + IllegalArgumentException none = assertThrows( + IllegalArgumentException.class, + () -> ExampleRowFilter.applyToDiscoveredSuite(empty, "99", null, null) + ); + assertTrue(none.getMessage().startsWith("No scenarios matched the provided filters:")); + assertTrue(none.getMessage().contains("example=[99]")); + assertEquals(before, empty.getChildren().size()); + + IllegalArgumentException invalid = assertThrows( + IllegalArgumentException.class, + () -> ExampleRowFilter.applyToDiscoveredSuite(root, "5-3", null, null) + ); + assertTrue(invalid.getMessage().contains("'5-3'")); + assertFalse(invalid.getMessage().startsWith("No scenarios matched")); + assertEquals(before, root.getChildren().size()); + } + + private static List scan(String features, String example, String tags, String name, String featureName) { + CucumberScanUtil.clearCache(); + Map props = new HashMap<>(); + props.put(PKB_FEATURES, features); + if (example != null) props.put(PKB_EXAMPLE, example); + if (tags != null) props.put(PKB_TAGS, tags); + if (name != null) props.put(PKB_NAME, name); + if (featureName != null) props.put(PKB_FEATURE_NAME, featureName); + return CucumberScanUtil.listPickles(props); + } + + private static List named(List pickles) { + List names = new ArrayList<>(); + for (Pickle pickle : pickles) { + names.add(pickle.getName() + ":" + cell(pickle)); + } + return names; + } + + private static List cells(List pickles) { + List values = new ArrayList<>(); + for (Pickle pickle : pickles) { + values.add(cell(pickle)); + } + return values; + } + + private static String cell(Pickle pickle) { + TableRow exampleRow = CucumberQueryUtil.describe(pickle).exampleRow; + if (exampleRow != null && exampleRow.getCells() != null && !exampleRow.getCells().isEmpty()) { + return exampleRow.getCells().getFirst().getValue(); + } + String text = pickle.getSteps().getFirst().getText(); + int space = text.lastIndexOf(' '); + return space < 0 ? text : text.substring(space + 1); + } + + private static Feature readFeature(Path file) throws IOException { + try (InputStream input = Files.newInputStream(file)) { + return new GherkinMessagesFeatureParser() + .parse(file.toUri(), input, UUID::randomUUID) + .orElseThrow(); + } + } + + private static TestDescriptor suite(Feature feature) { + UniqueId rootId = UniqueId.forEngine("cucumber"); + RowNode root = new RowNode(rootId, "suite", TestDescriptor.Type.CONTAINER); + String uri = feature.getUri().toString(); + for (Pickle pickle : feature.getPickles()) { + int line = pickle.getLocation().getLine(); + UniqueId id = rootId.append("feature", uri); + if (pickle.getExamplesLocation().isPresent()) { + id = id.append("scenario", "1") + .append("examples", "1") + .append("example", Integer.toString(line)); + } else { + id = id.append("scenario", Integer.toString(line)); + } + root.addChild(new RowNode(id, cell(pickle), TestDescriptor.Type.TEST)); + } + return root; + } + + private static Map counts(TestDescriptor root) { + Map counts = new LinkedHashMap<>(); + for (TestDescriptor child : root.getChildren()) { + if (child.getType() == TestDescriptor.Type.TEST) { + counts.merge(child.getDisplayName(), 1, Integer::sum); + } + } + return counts; + } + + private static final class RowNode extends AbstractTestDescriptor { + private final TestDescriptor.Type type; + + private RowNode(UniqueId uniqueId, String displayName, TestDescriptor.Type type) { + super(uniqueId, displayName); + this.type = type; + } + + @Override + public TestDescriptor.Type getType() { + return type; + } + } +} From 3ffa15b22e5b3e3741bf13269bf8dea2824b1a09 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 09:30:51 -0700 Subject: [PATCH 03/23] Bottom line: the Workbench window calls the same session commands an agent can run. Open, example, play, step, stop, diagnostic run, save, and the worker controls share one command path. The window is a client of that path. Discover and sync use the project wrapper and do not fall back to a machine-wide Maven or Gradle install. --- .../control/protocol/ExampleRowSelector.java | 167 ++++++ .../protocol/ExampleRowSelectorTest.java | 73 +++ .../workbench/WorkbenchApplication.java | 6 +- .../dscode/workbench/WorkbenchController.java | 26 +- .../dscode/workbench/WorkbenchServices.java | 16 + .../workbench/WorkbenchSessionActions.java | 519 ++++++++++++++++++ .../workbench/mcp/WorkbenchAttachServer.java | 50 +- .../workbench/mcp/WorkbenchCommandQueue.java | 46 +- .../workbench/player/GherkinPlayPlan.java | 30 +- .../player/LivePlaybackCoordinator.java | 75 ++- .../workbench/player/ScenarioOrigin.java | 32 +- .../workbench/sync/WorkbenchProject.java | 18 +- .../workbench/sync/WorkbenchSynchronizer.java | 14 +- .../dscode/workbench/ui/WorkbenchFrame.java | 355 ++++++++---- .../workbench/ui/WorkbenchUiController.java | 25 +- .../WorkbenchSessionActionsTest.java | 222 ++++++++ .../mcp/WorkbenchAttachServerTest.java | 57 ++ .../mcp/WorkbenchCommandQueueTest.java | 14 + .../workbench/player/GherkinPlayPlanTest.java | 31 ++ .../sync/WorkbenchSynchronizerTest.java | 35 ++ .../ui/WorkbenchUiControllerTest.java | 2 +- .../diagnostic/ConsumerMavenTestRunner.java | 30 +- .../launcher/WorkbenchAgentCommands.java | 4 +- .../dscode/launcher/WorkbenchCommandLine.java | 5 +- .../launcher/WorkbenchSessionCommands.java | 182 +++++- .../dscode/testengine/ExampleRowFilter.java | 102 +--- .../ConsumerMavenTestRunnerTest.java | 44 ++ .../PickleballWorkbenchLauncherTest.java | 10 + .../launcher/WorkbenchAgentCommandsTest.java | 14 + .../WorkbenchSessionCommandsTest.java | 143 ++++- 30 files changed, 2076 insertions(+), 271 deletions(-) create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ExampleRowSelector.java create mode 100644 pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java create mode 100644 src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ExampleRowSelector.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ExampleRowSelector.java new file mode 100644 index 000000000..2705f320a --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ExampleRowSelector.java @@ -0,0 +1,167 @@ +package tools.dscode.control.protocol; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * JDK-only Examples-row selector shared by Pickleball {@code pkb_example} and + * the Workbench player. Tokens are a positive integer, {@code table.row}, or + * an inclusive overall {@code low-high} range. No leading zeros. + */ +public final class ExampleRowSelector { + private static final Pattern INTEGER = Pattern.compile("[1-9][0-9]*"); + private static final Pattern TABLE_ROW = Pattern.compile("([1-9][0-9]*)\\.([1-9][0-9]*)"); + private static final Pattern RANGE = Pattern.compile("([1-9][0-9]*)-([1-9][0-9]*)"); + + private final List clauses; + + private ExampleRowSelector(List clauses) { + this.clauses = List.copyOf(clauses); + } + + public static boolean isInactive(String expression) { + return expression == null || expression.isBlank(); + } + + public static ExampleRowSelector parse(String expression) { + if (isInactive(expression)) { + throw invalid(""); + } + String[] tokens = expression.trim().split("\\s+"); + List clauses = new ArrayList<>(); + for (String token : tokens) { + if (token == null || token.isEmpty()) { + throw invalid(""); + } + clauses.add(parseToken(token)); + } + if (clauses.isEmpty()) { + throw invalid(""); + } + return new ExampleRowSelector(clauses); + } + + public boolean matches(int overall, int table, int row) { + for (Clause clause : clauses) { + if (clause.matches(overall, table, row)) { + return true; + } + } + return false; + } + + /** + * First value-row match in source order. Table numbers and value rows are + * 1-based. The header row of each Examples table is not a value row. + * Returns 0 when nothing matches. + */ + public static int firstOverallIndex(List lines, int startInclusive, int endExclusive, String expression) { + if (isInactive(expression) || lines == null || lines.isEmpty()) { + return 0; + } + ExampleRowSelector selector = parse(expression); + int table = 0; + int overall = 0; + int rowInTable = 0; + boolean headerSeen = false; + int from = Math.max(0, startInclusive); + int to = Math.min(lines.size(), endExclusive < 0 ? lines.size() : endExclusive); + for (int i = from; i < to; i++) { + String trimmed = lines.get(i) == null ? "" : lines.get(i).strip(); + if (startsWithKeyword(trimmed, "Examples:") || startsWithKeyword(trimmed, "Example:")) { + table++; + headerSeen = false; + rowInTable = 0; + continue; + } + if (!trimmed.startsWith("|")) { + continue; + } + if (!headerSeen) { + headerSeen = true; + continue; + } + rowInTable++; + overall++; + if (selector.matches(overall, table, rowInTable)) { + return overall; + } + } + // A normal scenario has no Examples table. It is one implicit row. + if (overall == 0 && table == 0 && selector.matches(1, 1, 1)) { + return 1; + } + return 0; + } + + private static boolean startsWithKeyword(String trimmed, String keyword) { + return trimmed.startsWith(keyword) || trimmed.startsWith(keyword.toLowerCase()); + } + + private static Clause parseToken(String token) { + if (INTEGER.matcher(token).matches()) { + return new Overall(parsePositive(token)); + } + Matcher table = TABLE_ROW.matcher(token); + if (table.matches()) { + return new TableRow(parsePositive(table.group(1)), parsePositive(table.group(2))); + } + Matcher range = RANGE.matcher(token); + if (range.matches()) { + int low = parsePositive(range.group(1)); + int high = parsePositive(range.group(2)); + if (low > high) { + throw invalid(token); + } + return new Range(low, high); + } + throw invalid(token); + } + + private static int parsePositive(String token) { + try { + int value = Integer.parseInt(token); + if (value <= 0) { + throw invalid(token); + } + return value; + } catch (NumberFormatException exception) { + throw invalid(token); + } + } + + private static IllegalArgumentException invalid(String token) { + return new IllegalArgumentException( + "Invalid pkb_example token '" + token + "'. " + + "Expected a positive integer, table.row, or low-high range of positive integers " + + "with no leading zeros." + ); + } + + private interface Clause { + boolean matches(int overall, int table, int row); + } + + private record Overall(int index) implements Clause { + @Override + public boolean matches(int overall, int table, int row) { + return overall == index; + } + } + + private record TableRow(int tableNumber, int rowInTable) implements Clause { + @Override + public boolean matches(int overall, int table, int row) { + return table == tableNumber && row == rowInTable; + } + } + + private record Range(int low, int high) implements Clause { + @Override + public boolean matches(int overall, int table, int row) { + return overall >= low && overall <= high; + } + } +} diff --git a/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java b/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java new file mode 100644 index 000000000..b63aedd9f --- /dev/null +++ b/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java @@ -0,0 +1,73 @@ +package tools.dscode.control.protocol; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExampleRowSelectorTest { + private static final List TWO_TABLES = List.of( + "Scenario Outline: Buy", + " When buy ", + " Examples:", + " | v |", + " | a |", + " | b |", + " | c |", + " Examples:", + " | v |", + " | d |", + " | e |", + " | f |" + ); + + @Test + void integerFiveIsTableTwoRowTwo() { + ExampleRowSelector selector = ExampleRowSelector.parse("5"); + assertTrue(selector.matches(5, 2, 2)); + assertFalse(selector.matches(4, 2, 1)); + assertEquals(5, ExampleRowSelector.firstOverallIndex(TWO_TABLES, 0, TWO_TABLES.size(), "5")); + assertEquals(5, ExampleRowSelector.firstOverallIndex(TWO_TABLES, 0, TWO_TABLES.size(), "2.2")); + } + + @Test + void listUsesTheFirstSourceMatch() { + assertEquals(1, ExampleRowSelector.firstOverallIndex(TWO_TABLES, 0, TWO_TABLES.size(), "5 1 2.2")); + } + + @Test + void normalScenarioWithoutExamplesIsImplicitRowOne() { + java.util.List lines = java.util.List.of( + "Feature: F", + " Scenario: Plain", + " Given stay" + ); + assertEquals(1, ExampleRowSelector.firstOverallIndex(lines, 0, lines.size(), "1")); + assertEquals(1, ExampleRowSelector.firstOverallIndex(lines, 0, lines.size(), "1.1")); + assertEquals(0, ExampleRowSelector.firstOverallIndex(lines, 0, lines.size(), "2")); + } + + @Test + void normalScenarioRowOneMatchesOneAndOnePointOne() { + ExampleRowSelector one = ExampleRowSelector.parse("1"); + ExampleRowSelector table = ExampleRowSelector.parse("1.1"); + assertTrue(one.matches(1, 1, 1)); + assertTrue(table.matches(1, 1, 1)); + assertFalse(ExampleRowSelector.parse("2").matches(1, 1, 1)); + } + + @Test + void invalidTokensFail() { + for (String token : List.of("0", "5-3", "2.0", "3.", "1.2.3")) { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> ExampleRowSelector.parse(token) + ); + assertTrue(failure.getMessage().contains("'" + token + "'")); + } + } +} 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 1b4bf1371..27f34b8c4 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java @@ -633,9 +633,9 @@ 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("isolate holds a paused worker from the last Discover snapshot when stdin is an interactive TTY, or when pickleball.workbench.isolate.once is set."); - out.println("session is the headless long-lived CLI controller: sync, start the Discover-snapshot worker, and serve 127.0.0.1 HTTP plus a serial execute-step queue. State is .pickleball/v//workbench/cli-session.json when current.json is complete."); - out.println("Consumer agents start session through PickleballWorkbenchLauncher isolate/session-start (detached). Do not start ui for agents."); + out.println("session is the headless long-lived CLI controller: sync, start the Discover-snapshot worker, and serve 127.0.0.1 HTTP plus the same session commands the window uses. State is .pickleball/v//workbench/cli-session.json when current.json is complete."); + out.println("Consumer agents start session through PickleballWorkbenchLauncher isolate/session-start (detached) when nobody is watching. When a window is already open, those same commands drive that session. Do not start ui for agents."); out.println("mcp serves the same Workbench services over protocol-only stdio; optional host wiring, not an agent setup step."); - 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 (versioned under v// when current.json is complete)."); + 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 (versioned under v// when current.json is complete). The window calls the session commands; it does not keep a second copy of them."); } } 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 a6618b601..295281511 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java @@ -175,8 +175,32 @@ public void loadPickerScenario( int exampleRow, String exampleLabel ) { + loadPickerScenario(lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel, ""); + } + + @Override + public void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine, + int exampleRow, + String exampleLabel, + String exampleSelector + ) { + requireMutating(); + playback.loadScenario( + lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel, exampleSelector + ); + publishLiveBufferSidecar(); + notifyPlayer(); + } + + @Override + public void selectExample(String selector) { requireMutating(); - playback.loadScenario(lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel); + playback.selectExample(selector); publishLiveBufferSidecar(); notifyPlayer(); } 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 057d89433..b1e20f2d4 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java @@ -84,6 +84,22 @@ default void loadPickerScenario( loadPickerScenario(lines, originFile, scenarioName, startLine, endLine); } + default void loadPickerScenario( + List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine, + int exampleRow, + String exampleLabel, + String exampleSelector + ) { + loadPickerScenario(lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel); + } + + default void selectExample(String selector) { + } + void loadDefaultDemo(); void replaceLiveDocument(List lines); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java new file mode 100644 index 000000000..24112bbb7 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java @@ -0,0 +1,519 @@ +package tools.dscode.workbench; + +import tools.dscode.control.protocol.ControlBridgeCallResult; +import tools.dscode.control.protocol.ExampleRowSelector; +import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; +import tools.dscode.workbench.lease.WorkbenchCallContext; +import tools.dscode.workbench.lease.WorkbenchLeaseHolder; +import tools.dscode.workbench.player.GherkinPlayPlan; +import tools.dscode.workbench.player.LiveScenarioPlayer; +import tools.dscode.workbench.player.WorkbenchSaveResult; +import tools.dscode.workbench.worker.WorkbenchWorkerStatus; + +import javax.swing.SwingUtilities; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +/** + * One implementation of the session actions the Workbench window and the + * command queue both call. The window is a client: when it is installed, the + * command asks that window to show the existing playback. Headless sessions + * use the same coordinator and services with no Swing. + */ +public final class WorkbenchSessionActions { + public static final Set OPS = Set.of( + "open-scenario", + "example", + "play", + "from-here", + "pause", + "execute-step", + "insert-step", + "update-step", + "diagnostic-run", + "save", + "refresh", + "session-sync", + "worker-start", + "worker-restart", + "worker-stop", + "stop" + ); + + private final WorkbenchServices services; + private volatile Window window; + private volatile String selectedDiagnosticRun = ""; + + public WorkbenchSessionActions(WorkbenchServices services) { + this.services = Objects.requireNonNull(services, "services"); + } + + public static boolean known(String op) { + return op != null && OPS.contains(op); + } + + public void installWindow(Window window) { + this.window = window; + } + + public String selectedDiagnosticRun() { + return selectedDiagnosticRun; + } + + public Map dispatch(String op, Map args) { + Map values = args == null ? Map.of() : args; + return WorkbenchCallContext.callAs(WorkbenchLeaseHolder.AGENT, () -> handle(op, values)); + } + + public Map openScenario(String feature, String name, String example) { + Opened opened = resolve(services.projectRoot(), feature, name, example); + if (window != null) { + show(client -> client.openResolved(opened)); + return accepted("open-scenario", "LOADED"); + } + services.loadPickerScenario( + opened.lines(), + opened.file(), + opened.name(), + opened.startLine(), + opened.endLine(), + opened.exampleRow(), + opened.exampleLabel(), + opened.exampleSelector() + ); + LinkedHashMap payload = accepted("open-scenario", "LOADED"); + payload.put("scenario", opened.name()); + payload.put("exampleRow", services.playback().origin().exampleRow()); + payload.put("example", opened.exampleSelector()); + return payload; + } + + public Map example(String selector) { + if (ExampleRowSelector.isInactive(selector)) { + throw new IllegalArgumentException("example selector must not be blank."); + } + ExampleRowSelector.parse(selector); + if (window != null) { + show(client -> client.selectExample(selector.strip())); + return accepted("example", selector.strip()); + } + services.selectExample(selector.strip()); + LinkedHashMap payload = accepted("example", selector.strip()); + payload.put("exampleRow", services.playback().origin().exampleRow()); + return payload; + } + + public Map play() { + return play(false, null); + } + + public Map fromHere(String stepId) { + return play(true, stepId); + } + + public Map pause() { + if (window != null) { + show(Window::pause); + return accepted("pause", "PAUSED"); + } + services.playback().pause(); + return accepted("pause", "PAUSED"); + } + + public Map stopPlayback() { + if (window != null) { + show(Window::stopPlayback); + return accepted("stop", "STOPPED"); + } + services.playback().stop(); + return accepted("stop", "STOPPED"); + } + + public Map executeStep(String text) { + if (window != null) { + show(client -> client.executeStep(text)); + return accepted("execute-step", "STARTED"); + } + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("execute-step text must not be blank."); + } + ControlBridgeCallResult result = services.executeStep(text, ""); + LinkedHashMap payload = new LinkedHashMap<>(); + String status = result != null && "SUCCESS".equals(result.status()) ? "SUCCESS" : "FAILED"; + payload.put("status", status); + payload.put("op", "execute-step"); + payload.put("playback", status); + return payload; + } + + public Map insertStep(String text) { + if (window != null) { + show(client -> client.insertStep(text)); + return accepted("insert-step", "INSERTED"); + } + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("insert-step text must not be blank."); + } + LiveScenarioPlayer.Line inserted = services.playback().insertAndMaybeContinue(text); + LinkedHashMap payload = accepted("insert-step", "INSERTED"); + payload.put("text", inserted.text()); + return payload; + } + + public Map updateStep(String text) { + if (window != null) { + show(client -> client.updateStep(text)); + return accepted("update-step", "UPDATED"); + } + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("update-step text must not be blank."); + } + LiveScenarioPlayer.Line updated = services.player().updateSelectedStep(text); + LinkedHashMap payload = accepted("update-step", "UPDATED"); + payload.put("text", updated.text()); + return payload; + } + + public Map diagnosticRun(String runId) { + if (runId == null || runId.isBlank()) { + throw new IllegalArgumentException("diagnostic-run requires a run id."); + } + String id = runId.strip(); + selectedDiagnosticRun = id; + if (window != null) { + show(client -> client.diagnosticRun(id)); + LinkedHashMap payload = accepted("diagnostic-run", "SHOWN"); + payload.put("run", id); + return payload; + } + Object document = services.diagnosticRun(id); + LinkedHashMap payload = accepted("diagnostic-run", "SHOWN"); + payload.put("run", id); + payload.put("document", document == null ? "" : document.toString()); + return payload; + } + + public Map save() { + if (window != null) { + WorkbenchSaveResult[] result = new WorkbenchSaveResult[1]; + show(client -> result[0] = client.presentSave()); + if (result[0] == null) return saveMap(WorkbenchSaveResult.cancelled("Save cancelled.")); + return saveMap(result[0]); + } + return saveMap(services.requestSave()); + } + + public Map refresh() { + if (window != null) { + show(Window::refreshStatus); + return accepted("refresh", "REFRESHED"); + } + return workerMap("refresh", services.workerStatus()); + } + + public Map sessionSync() { + if (window != null) { + show(Window::syncProject); + return accepted("session-sync", "STARTED"); + } + services.synchronize(); + return accepted("session-sync", "DONE"); + } + + public Map workerStart() { + if (window != null) { + show(Window::startWorker); + return accepted("worker-start", "STARTED"); + } + return workerMap("worker-start", services.startWorker()); + } + + public Map workerRestart() { + if (window != null) { + show(Window::restartWorker); + return accepted("worker-restart", "STARTED"); + } + return workerMap("worker-restart", services.restartWorker()); + } + + public Map workerStop() { + if (window != null) { + show(client -> { + client.stopPlayback(); + client.stopWorker(); + }); + return accepted("worker-stop", "STOPPED"); + } + services.playback().stop(); + return workerMap("worker-stop", services.stopWorker()); + } + + public static Opened resolve(Path projectRoot, String feature, String name, String example) { + Objects.requireNonNull(projectRoot, "projectRoot"); + if ((feature == null || feature.isBlank()) && (name == null || name.isBlank())) { + throw new IllegalArgumentException("open-scenario requires a feature path or name and a scenario name."); + } + String selector = example == null ? "" : example.strip(); + if (!ExampleRowSelector.isInactive(selector)) { + ExampleRowSelector.parse(selector); + } + ConsumerFeatureCatalog catalog = ConsumerFeatureCatalog.scan(projectRoot, null); + ConsumerFeatureCatalog.ScenarioEntry chosen = null; + for (ConsumerFeatureCatalog.ScenarioEntry entry : catalog.candidateScenarios()) { + if (!featureMatches(entry, feature) || !nameMatches(entry, name)) continue; + if (chosen == null || (entry.exampleRow() == 0 && chosen.exampleRow() != 0)) { + chosen = entry; + } + } + if (chosen == null) { + throw new IllegalArgumentException( + "No scenario matched feature '" + (feature == null ? "" : feature.strip()) + + "' name '" + (name == null ? "" : name.strip()) + "'." + ); + } + List lines = readFeature(chosen.file(), chosen.lines()); + int row = chosen.exampleRow(); + if (ExampleRowSelector.isInactive(selector) && row > 0) { + selector = Integer.toString(row); + } + return new Opened( + chosen.file(), + chosen.name(), + chosen.startLine(), + chosen.endLine(), + lines, + ExampleRowSelector.isInactive(selector) ? row : 0, + chosen.exampleLabel(), + selector + ); + } + + private Map handle(String op, Map args) { + if (!known(op)) { + throw new IllegalArgumentException("Unknown session command: " + op); + } + return switch (op) { + case "open-scenario" -> openScenario(args.get("feature"), args.get("name"), args.get("example")); + case "example" -> example(first(args, "example", "text")); + case "play" -> play(); + case "from-here" -> fromHere(first(args, "fromHere", "text")); + case "pause" -> pause(); + case "execute-step" -> executeStep(args.get("text")); + case "insert-step" -> insertStep(args.get("text")); + case "update-step" -> updateStep(args.get("text")); + case "diagnostic-run" -> diagnosticRun(first(args, "run", "text")); + case "save" -> save(); + case "refresh" -> refresh(); + case "session-sync" -> sessionSync(); + case "worker-start" -> workerStart(); + case "worker-restart" -> workerRestart(); + case "worker-stop" -> workerStop(); + case "stop" -> stopPlayback(); + default -> throw new IllegalArgumentException("Unknown session command: " + op); + }; + } + + private Map play(boolean fromHere, String stepId) { + if (window != null) { + show(client -> { + if (fromHere) client.playFromHere(stepId); + else client.playFromStart(); + }); + return accepted(fromHere ? "from-here" : "play", "STARTED"); + } + if (fromHere && stepId != null && !stepId.isBlank()) { + try { + services.playback().seek(Long.parseLong(stepId.strip())); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("from-here expects a numeric step id."); + } + } + return headlessPlay(fromHere); + } + + private Map headlessPlay(boolean fromHere) { + String op = fromHere ? "from-here" : "play"; + try { + if (fromHere) services.playback().playFromHere(); + else services.playback().playFromStart(); + int executed = 0; + int guard = Math.max(1, services.playback().playPlan().steps().size()) + 2; + while (services.playback().running() && executed < guard) { + GherkinPlayPlan.Step step = services.playback().nextPlanStep().orElse(null); + if (step == null) break; + int before = services.playback().planIndex(); + ControlBridgeCallResult result = services.executeStep(step.executeText(), ""); + executed++; + if (result == null || !"SUCCESS".equals(result.status())) { + LinkedHashMap failed = failed(op, result == null ? "no result" : result.status()); + failed.put("steps", executed); + return failed; + } + if (services.playback().running() && services.playback().planIndex() == before) { + LinkedHashMap failed = failed(op, "playback did not advance"); + failed.put("steps", executed); + return failed; + } + } + LinkedHashMap payload = accepted(op, "FINISHED"); + payload.put("steps", executed); + return payload; + } catch (RuntimeException failure) { + return failed(op, failure.getMessage() == null ? failure.toString() : failure.getMessage()); + } + } + + private void show(Consumer call) { + Window client = window; + if (client == null) return; + WorkbenchLeaseHolder holder = WorkbenchCallContext.current(); + Runnable job = () -> WorkbenchCallContext.runAs(holder, () -> { + client.beginCommand(); + try { + call.accept(client); + } finally { + client.endCommand(); + } + }); + if (SwingUtilities.isEventDispatchThread()) { + job.run(); + return; + } + try { + SwingUtilities.invokeAndWait(job); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while updating the Workbench window.", interrupted); + } catch (InvocationTargetException failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + throw new IllegalStateException(cause); + } + } + + private static String first(Map args, String primary, String fallback) { + String value = args.get(primary); + if (value != null && !value.isBlank()) return value; + return args.get(fallback); + } + + private static boolean featureMatches(ConsumerFeatureCatalog.ScenarioEntry entry, String feature) { + if (feature == null || feature.isBlank()) return true; + String wanted = feature.strip(); + if (entry.featureName().equalsIgnoreCase(wanted)) return true; + String norm = wanted.replace('\\', '/'); + String path = entry.file().toString().replace('\\', '/'); + String relative = entry.relativePath() == null ? "" : entry.relativePath().replace('\\', '/'); + return path.equalsIgnoreCase(norm) + || path.toLowerCase(Locale.ROOT).endsWith("/" + norm.toLowerCase(Locale.ROOT)) + || relative.equalsIgnoreCase(norm) + || relative.toLowerCase(Locale.ROOT).endsWith(norm.toLowerCase(Locale.ROOT)); + } + + private static boolean nameMatches(ConsumerFeatureCatalog.ScenarioEntry entry, String name) { + if (name == null || name.isBlank()) return true; + return entry.name().equalsIgnoreCase(name.strip()); + } + + private static List readFeature(Path file, List fallback) { + if (file != null && Files.isRegularFile(file)) { + try { + return Files.readAllLines(file); + } catch (java.io.IOException ignored) { + // Fall back to the catalog body. + } + } + return new ArrayList<>(fallback == null ? List.of() : fallback); + } + + private static LinkedHashMap accepted(String op, String playback) { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("status", "SUCCESS"); + payload.put("op", op); + payload.put("playback", playback); + return payload; + } + + private static LinkedHashMap failed(String op, String message) { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("status", "FAILED"); + payload.put("op", op); + payload.put("message", message == null ? "" : message); + return payload; + } + + private static Map workerMap(String op, WorkbenchWorkerStatus status) { + LinkedHashMap payload = accepted(op, status != null && status.running() ? "RUNNING" : "STOPPED"); + payload.put("running", status != null && status.running()); + return payload; + } + + private static Map saveMap(WorkbenchSaveResult result) { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("status", result != null && result.written() ? "SUCCESS" : "FAILED"); + payload.put("op", "save"); + payload.put("save", result == null ? "CANCELLED" : result.status()); + payload.put("message", result == null ? "" : result.message()); + return payload; + } + + public record Opened( + Path file, + String name, + int startLine, + int endLine, + List lines, + int exampleRow, + String exampleLabel, + String exampleSelector + ) { + } + + /** Swing callbacks. They show the existing window behavior and do not call back into the command. */ + public interface Window { + void beginCommand(); + + void endCommand(); + + void openResolved(Opened opened); + + void selectExample(String selector); + + void playFromStart(); + + void playFromHere(String stepId); + + void pause(); + + void stopPlayback(); + + void executeStep(String text); + + void insertStep(String text); + + void updateStep(String text); + + void diagnosticRun(String runId); + + WorkbenchSaveResult presentSave(); + + void syncProject(); + + void refreshStatus(); + + void startWorker(); + + void restartWorker(); + + void stopWorker(); + } +} 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 index 791128c46..b33aeba07 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java @@ -6,6 +6,7 @@ import com.sun.net.httpserver.HttpServer; import tools.dscode.control.protocol.PickleballLocalLayout; import tools.dscode.workbench.WorkbenchServices; +import tools.dscode.workbench.WorkbenchSessionActions; import java.io.IOException; import java.io.InputStream; @@ -42,6 +43,7 @@ public final class WorkbenchAttachServer implements AutoCloseable { private final HttpServer http; private final WorkbenchMcpTools tools; private final WorkbenchCommandQueue commands; + private final WorkbenchSessionActions actions; private final ExecutorService executor; private final AtomicBoolean closed = new AtomicBoolean(); @@ -54,6 +56,7 @@ private WorkbenchAttachServer( HttpServer http, WorkbenchMcpTools tools, WorkbenchCommandQueue commands, + WorkbenchSessionActions actions, ExecutorService executor ) { this.services = services; @@ -64,6 +67,7 @@ private WorkbenchAttachServer( this.http = http; this.tools = tools; this.commands = commands; + this.actions = actions; this.executor = executor; } @@ -113,12 +117,18 @@ private static WorkbenchAttachServer start( HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); String token = newToken(); WorkbenchMcpTools tools = new WorkbenchMcpTools(services, JSON); - WorkbenchCommandQueue commands = null; - if (CLI_SESSION_MODE.equals(mode)) { - commands = queue != null ? queue : new WorkbenchCommandQueue( - text -> tools.call("workbench_execute_step", Map.of("text", text)) - ); - if (stopHandler != null) commands.setStopHandler(stopHandler); + WorkbenchSessionActions actions = new WorkbenchSessionActions(services); + WorkbenchCommandQueue commands = queue != null ? queue : new WorkbenchCommandQueue( + text -> tools.call("workbench_execute_step", Map.of("text", text)) + ); + commands.setOpHandler((op, args) -> actions.dispatch(op, args)); + if (stopHandler != null) { + commands.setStopHandler(() -> { + actions.stopPlayback(); + stopHandler.run(); + }); + } else if (UI_ATTACH_MODE.equals(mode)) { + commands.setStopHandler(actions::stopPlayback); } ExecutorService executor = Executors.newCachedThreadPool(runnable -> { Thread thread = new Thread(runnable, "pickleball-workbench-attach"); @@ -126,15 +136,13 @@ private static WorkbenchAttachServer start( return thread; }); WorkbenchAttachServer server = new WorkbenchAttachServer( - services, root, stateFile, token, mode, http, tools, commands, executor + services, root, stateFile, token, mode, http, tools, commands, actions, executor ); http.createContext("/health", server::health); http.createContext("/lease", server::lease); http.createContext("/player", server::player); http.createContext("/tools", server::tools); - if (CLI_SESSION_MODE.equals(mode)) { - http.createContext("/commands", server::commands); - } + http.createContext("/commands", server::commands); http.setExecutor(executor); http.start(); server.writeStateFile(); @@ -172,6 +180,10 @@ WorkbenchCommandQueue commandQueue() { return commands; } + public WorkbenchSessionActions sessionActions() { + return actions; + } + @Override public void close() { if (!closed.compareAndSet(false, true)) return; @@ -251,16 +263,16 @@ private void commands(HttpExchange exchange) throws IOException { if ("/commands".equals(path) && "POST".equals(exchange.getRequestMethod())) { Map body = readJsonObject(exchange.getRequestBody()); String op = string(body, "op"); - if ("execute-step".equals(op)) { - send(exchange, 200, commands.enqueueExecuteStep(string(body, "text"), string(body, "id"))); - return; - } if ("stop".equals(op) || "kill".equals(op)) { send(exchange, 200, Map.of("ack", true, "status", "STOPPING", "op", op)); commands.requestStop(); return; } - send(exchange, 400, Map.of("error", "Unknown command op: " + op)); + if (!WorkbenchSessionActions.known(op)) { + send(exchange, 400, Map.of("error", "Unknown command op: " + op)); + return; + } + send(exchange, 200, commands.enqueueOp(op, stringArgs(body), string(body, "id"))); return; } if (path.startsWith("/commands/") && path.length() > "/commands/".length() @@ -323,6 +335,14 @@ private static String string(Map body, String key) { return value == null ? null : value.toString(); } + private static Map stringArgs(Map body) { + LinkedHashMap args = new LinkedHashMap<>(); + for (Map.Entry entry : body.entrySet()) { + if (entry.getValue() != null) args.put(entry.getKey(), entry.getValue().toString()); + } + return args; + } + 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"); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchCommandQueue.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchCommandQueue.java index c0e65fb82..92127fd7b 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchCommandQueue.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchCommandQueue.java @@ -39,6 +39,12 @@ final class WorkbenchCommandQueue implements AutoCloseable { private final AtomicBoolean closed = new AtomicBoolean(); private final AtomicBoolean silenced = new AtomicBoolean(); private volatile Runnable stopHandler; + private volatile OpHandler opHandler; + + @FunctionalInterface + interface OpHandler { + Object handle(String op, Map args); + } WorkbenchCommandQueue(Function executeStep) { this(executeStep, Duration.ofSeconds(2)); @@ -64,14 +70,34 @@ void setStopHandler(Runnable stopHandler) { this.stopHandler = stopHandler; } + void setOpHandler(OpHandler opHandler) { + this.opHandler = opHandler; + } + Map enqueueExecuteStep(String text, String requestedId) { if (text == null || text.isBlank()) { throw new IllegalArgumentException("execute-step text must not be blank."); } + LinkedHashMap args = new LinkedHashMap<>(); + args.put("text", text); + return enqueueOp("execute-step", args, requestedId); + } + + Map enqueueOp(String op, Map args, String requestedId) { + if (op == null || op.isBlank()) { + throw new IllegalArgumentException("Command op must not be blank."); + } + Map values = args == null ? Map.of() : Map.copyOf(args); + if ("execute-step".equals(op)) { + String text = values.get("text"); + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("execute-step text must not be blank."); + } + } String id = requestedId == null || requestedId.isBlank() ? UUID.randomUUID().toString() : requestedId.strip(); - Command command = new Command(id, text); + Command command = new Command(id, op, values); if (commands.putIfAbsent(id, command) != null) { throw new IllegalArgumentException("Command id already exists: " + id); } @@ -113,7 +139,10 @@ public void close() { private void run(Command command) { command.markRunning(); try { - Object value = executeStep.apply(command.text); + OpHandler handler = opHandler; + Object value = handler != null + ? handler.handle(command.op, command.args) + : executeStep.apply(command.text()); command.complete(resultStatus(value), value); } catch (RuntimeException failure) { LinkedHashMap error = new LinkedHashMap<>(); @@ -148,13 +177,20 @@ private static String resultStatus(Object value) { private static final class Command { private final String id; - private final String text; + private final String op; + private final Map args; private volatile String stored = QUEUED; private volatile Object result; - private Command(String id, String text) { + private Command(String id, String op, Map args) { this.id = id; - this.text = text; + this.op = op; + this.args = args; + } + + private String text() { + String text = args.get("text"); + return text == null ? "" : text; } private void markRunning() { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinPlayPlan.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinPlayPlan.java index 10c0eaf41..0affaa25f 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinPlayPlan.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/GherkinPlayPlan.java @@ -1,5 +1,7 @@ package tools.dscode.workbench.player; +import tools.dscode.control.protocol.ExampleRowSelector; + import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -46,7 +48,7 @@ public static GherkinPlayPlan from(LiveScenarioPlayer player, ScenarioOrigin ori } List background = backgroundSteps(lines, feature, scenario); List body = scenarioSteps(lines, scenario); - Map substitutions = exampleSubstitutions(lines, scenario, target.exampleRow()); + Map substitutions = exampleSubstitutions(lines, scenario, target); List planned = new ArrayList<>(); for (LiveScenarioPlayer.Line line : background) { planned.add(new Step(line.id(), line.text(), substitute(line.text(), substitutions))); @@ -118,21 +120,35 @@ private static List collectSteps( private static Map exampleSubstitutions( List lines, Region scenario, - int exampleRow + ScenarioOrigin origin ) { - if (exampleRow <= 0) return Map.of(); + String expression = origin == null ? "" : origin.activeExampleSelector(); + if (ExampleRowSelector.isInactive(expression)) return Map.of(); + ExampleRowSelector selector = ExampleRowSelector.parse(expression); + int table = 0; + int overall = 0; + int rowInTable = 0; + boolean headerSeen = false; List header = null; - int dataIndex = 0; for (int i = scenario.start; i < scenario.end && i < lines.size(); i++) { String trimmed = lines.get(i).text().strip(); + if (startsWithKeyword(trimmed, "Examples:") || startsWithKeyword(trimmed, "Example:")) { + table++; + headerSeen = false; + header = null; + rowInTable = 0; + continue; + } if (!trimmed.startsWith("|")) continue; List cells = tableCells(trimmed); - if (header == null) { + if (!headerSeen) { + headerSeen = true; header = cells; continue; } - dataIndex++; - if (dataIndex == exampleRow) { + rowInTable++; + overall++; + if (header != null && selector.matches(overall, table, rowInTable)) { LinkedHashMap map = new LinkedHashMap<>(); int n = Math.min(header.size(), cells.size()); for (int c = 0; c < n; c++) { 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 2f3c759b4..8a082861e 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 @@ -1,5 +1,8 @@ package tools.dscode.workbench.player; +import tools.dscode.control.protocol.ExampleRowSelector; + +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -79,13 +82,75 @@ public void loadScenario( int exampleRow, String exampleLabel ) { + loadScenario(lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel, ""); + } + + public void loadScenario( + List lines, + java.nio.file.Path originFile, + String scenarioName, + int startLine, + int endLine, + int exampleRow, + String exampleLabel, + String exampleSelector + ) { + String selector = exampleSelector == null ? "" : exampleSelector.strip(); + if (ExampleRowSelector.isInactive(selector) && exampleRow > 0) { + selector = Integer.toString(exampleRow); + } + if (!ExampleRowSelector.isInactive(selector)) { + ExampleRowSelector.parse(selector); + } origin = originFile == null ? ScenarioOrigin.none() - : new ScenarioOrigin(originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel); + : new ScenarioOrigin( + originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel, selector + ); player.loadDocument(lines); + if (originFile != null && !ExampleRowSelector.isInactive(selector)) { + int resolved = ExampleRowSelector.firstOverallIndex( + lineTexts(), + startLine > 0 ? startLine - 1 : 0, + endLine > 0 ? endLine : -1, + selector + ); + if (resolved <= 0) { + throw new IllegalArgumentException("No Examples row matched '" + selector + "'."); + } + String label = exampleLabel == null || exampleLabel.isBlank() ? "row " + resolved : exampleLabel; + origin = new ScenarioOrigin( + originFile, scenarioName, startLine, endLine, resolved, label, selector + ); + } + rebuildPlan(); + } + + public void selectExample(String expression) { + if (ExampleRowSelector.isInactive(expression)) { + throw new IllegalArgumentException("example selector must not be blank."); + } + ExampleRowSelector.parse(expression); + String selector = expression.strip(); + int start = origin.startLine() > 0 ? origin.startLine() - 1 : 0; + int end = origin.endLine() > 0 ? origin.endLine() : -1; + int resolved = ExampleRowSelector.firstOverallIndex(lineTexts(), start, end, selector); + if (resolved <= 0) { + throw new IllegalArgumentException("No Examples row matched '" + selector + "'."); + } + String label = origin.exampleLabel(); + if (label.isBlank() || label.startsWith("row ")) label = "row " + resolved; + origin = new ScenarioOrigin( + origin.file(), origin.scenarioName(), origin.startLine(), origin.endLine(), + resolved, label, selector + ); rebuildPlan(); } + public int planIndex() { + return planIndex; + } + public GherkinBlockDocument blocks() { return GherkinBlockDocument.fromPlayer(player); } @@ -191,4 +256,12 @@ public void followExecutedStep(String text, boolean successful) { private void seekPlanPlayhead() { nextPlanStep().ifPresent(step -> player.clickLine(step.sourceLineId())); } + + private List lineTexts() { + List texts = new ArrayList<>(); + for (LiveScenarioPlayer.Line line : player.lines()) { + texts.add(line.text()); + } + return texts; + } } 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 index bed0fb92e..0fc071d4c 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/player/ScenarioOrigin.java @@ -11,21 +11,40 @@ public record ScenarioOrigin( int startLine, int endLine, int exampleRow, - String exampleLabel + String exampleLabel, + String exampleSelector ) { public ScenarioOrigin { scenarioName = scenarioName == null ? "" : scenarioName; exampleLabel = exampleLabel == null ? "" : exampleLabel; + exampleSelector = exampleSelector == null ? "" : exampleSelector; } public ScenarioOrigin(Path file, String scenarioName, int startLine, int endLine) { this(file, scenarioName, startLine, endLine, 0, ""); } + public ScenarioOrigin( + Path file, + String scenarioName, + int startLine, + int endLine, + int exampleRow, + String exampleLabel + ) { + this(file, scenarioName, startLine, endLine, exampleRow, exampleLabel, ""); + } + public static ScenarioOrigin none() { return new ScenarioOrigin(null, "", 0, 0, 0, ""); } + /** Blank selector plus a positive row means that overall row number. */ + public String activeExampleSelector() { + if (!exampleSelector.isBlank()) return exampleSelector.strip(); + return exampleRow > 0 ? Integer.toString(exampleRow) : ""; + } + public boolean savable() { return file != null; } @@ -39,10 +58,17 @@ public boolean hasExampleRow() { } public ScenarioOrigin withEndLine(int newEndLine) { - return new ScenarioOrigin(file, scenarioName, startLine, newEndLine, exampleRow, exampleLabel); + return new ScenarioOrigin(file, scenarioName, startLine, newEndLine, exampleRow, exampleLabel, exampleSelector); } public ScenarioOrigin withExample(int row, String label) { - return new ScenarioOrigin(file, scenarioName, startLine, endLine, row, label); + return new ScenarioOrigin(file, scenarioName, startLine, endLine, row, label, ""); + } + + public ScenarioOrigin withExampleSelector(String selector) { + return new ScenarioOrigin( + file, scenarioName, startLine, endLine, exampleRow, exampleLabel, + selector == null ? "" : selector + ); } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java index 0917c2ab7..9a6fcfa9a 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java @@ -56,10 +56,20 @@ private static Path findLauncher(Path buildRoot, Type type) { if (isWindows() && Files.isRegularFile(windowsPath)) return windowsPath; if (Files.isRegularFile(unixPath)) return unixPath; if (Files.isRegularFile(windowsPath)) return windowsPath; - if (isWindows()) { - return Path.of(type == Type.MAVEN ? "mvn.cmd" : "gradle.bat"); - } - return Path.of(type == Type.MAVEN ? "mvn" : "gradle"); + Path jar = wrapperJar(buildRoot, type); + if (Files.isRegularFile(jar)) return jar; + String script = isWindows() ? windows : unix; + throw new IllegalArgumentException( + "No project wrapper for " + type + " at " + buildRoot + + ". Expected " + script + " or " + jar + + ". Pickleball runs the project wrapper and does not use a machine-wide Maven or Gradle install." + ); + } + + static Path wrapperJar(Path buildRoot, Type type) { + return type == Type.MAVEN + ? buildRoot.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.jar") + : buildRoot.resolve("gradle").resolve("wrapper").resolve("gradle-wrapper.jar"); } private static boolean hasWrapper(Path directory, Type type) { diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java index 7b100a149..2d7193b59 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSynchronizer.java @@ -387,10 +387,14 @@ private String runProcess(WorkbenchProject project, List args, Path log) } } - private static List executableCommand(Path launcher, List args) { + static List executableCommand(Path launcher, List args) { String name = launcher.getFileName() == null ? launcher.toString() : launcher.getFileName().toString(); List command = new ArrayList<>(); - if (WorkbenchProject.isWindows() && (name.endsWith(".cmd") || name.endsWith(".bat"))) { + if (name.endsWith(".jar")) { + command.add(javaExecutable()); + command.add("-jar"); + command.add(launcher.toString()); + } else if (WorkbenchProject.isWindows() && (name.endsWith(".cmd") || name.endsWith(".bat"))) { command.add("cmd.exe"); command.add("/d"); command.add("/c"); @@ -405,6 +409,12 @@ private static List executableCommand(Path launcher, List args) return command; } + private static String javaExecutable() { + String binary = WorkbenchProject.isWindows() ? "java.exe" : "java"; + Path bin = Path.of(System.getProperty("java.home"), "bin", binary); + return Files.isRegularFile(bin) ? bin.toString() : binary; + } + private static String gradleInitScript() { return """ import groovy.json.JsonOutput 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 d92ea56a2..f78f8742d 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 @@ -8,6 +8,7 @@ import tools.dscode.control.protocol.PickleballLocalLayout; import tools.dscode.control.protocol.PickleballVersion; import tools.dscode.control.protocol.ControlBridgeStepResolution; +import tools.dscode.workbench.WorkbenchSessionActions; import tools.dscode.workbench.catalog.CatalogTargetResolver; import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; import tools.dscode.workbench.catalog.JavaGlueIndex; @@ -59,6 +60,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Supplier; @@ -157,6 +159,8 @@ final class WorkbenchFrame extends JFrame { private boolean playbackPreparing; private boolean playbackBusy; private Long executingStepId; + private final AtomicBoolean commandDriven = new AtomicBoolean(); + private final WorkbenchSessionActions actions; private boolean pendingFreshRun; private Long pendingFreshRunStepId; private String pendingIsolatedStep; @@ -179,6 +183,9 @@ final class WorkbenchFrame extends JFrame { super("Pickleball Workbench"); this.controller = controller; this.attach = attach; + this.actions = attach == null + ? new WorkbenchSessionActions(controller.services()) + : attach.sessionActions(); this.player = controller.player(); this.playback = controller.playback(); editorSessions.add(new EditorTabState("Demo", null, player.documentText(), true)); @@ -242,6 +249,7 @@ final class WorkbenchFrame extends JFrame { updatePlayerView(null); })); applyLease(controller.controlLease()); + actions.installWindow(new SessionWindow()); controller.setUiGoHandler(link -> SwingUtilities.invokeLater(() -> applyGoResult(new WorkbenchGoResolver(controller.projectRoot()).resolve(link), link))); @@ -687,8 +695,14 @@ public void mousePressed(MouseEvent event) { } private void configureStepEditor() { - openTargetButton.addActionListener(event -> openSelectedTarget()); - stepText.addActionListener(event -> insertStep()); + openTargetButton.addActionListener(event -> { + if (humanControlsLocked()) return; + openSelectedTarget(); + }); + stepText.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.insertStep(stepText.getText()); + }); stepText.getInputMap(JComponent.WHEN_FOCUSED).put( KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, KeyEvent.CTRL_DOWN_MASK), "update-selected-step" @@ -696,7 +710,8 @@ private void configureStepEditor() { stepText.getActionMap().put("update-selected-step", new AbstractAction() { @Override public void actionPerformed(java.awt.event.ActionEvent event) { - updateSelectedStep(); + if (humanControlsLocked()) return; + actions.updateStep(stepText.getText()); } }); } @@ -751,8 +766,8 @@ private void configureWebViews() { ); diagnosticHost.onSelectRun(runId -> { - if (LIVE_ISOLATE_RUN.equals(runId)) showLiveIsolate(); - else showDiagnosticRun(runId); + if (humanControlsLocked()) return; + actions.diagnosticRun(runId); }); diagnosticHost.onGo(this::goFromExplorer); diagnosticHost.onReady(this::refreshDiagnostics); @@ -771,34 +786,14 @@ private void configureWebViews() { private void configurePicker() { picker.onScenarioSelected(scenario -> { - flushActiveEditorTab(); - List fileLines = readFeatureFile(scenario.file(), scenario.lines()); - controller.loadPickerScenario( - fileLines, - scenario.file(), - scenario.name(), - scenario.startLine(), - scenario.endLine(), - scenario.exampleRow(), - scenario.exampleLabel() - ); - picker.setSaveEnabled(true); - openEditorTab( - scenario.file(), - scenario.displayLabel(), - scenario.name(), - scenario.startLine(), - scenario.endLine(), - scenario.exampleRow(), - scenario.exampleLabel(), - player.documentText(), - true - ); - syncScenarioView(); - updatePlayerView("Loaded " + scenario.displayLabel() + "."); - resolveSelectedStep(); + if (humanControlsLocked()) return; + String example = scenario.exampleRow() > 0 ? Integer.toString(scenario.exampleRow()) : ""; + actions.openScenario(scenario.file().toString(), scenario.name(), example); + }); + picker.onSave(() -> { + if (humanControlsLocked()) return; + actions.save(); }); - picker.onSave(this::saveLoadedFeature); } private void refreshFeatureCatalog() { @@ -1007,12 +1002,14 @@ private void applyMappingPropertyEdit(MappingEditorHost.PropertyEdit edit) { } } - private void saveLoadedFeature() { - if (humanControlsLocked()) return; + private WorkbenchSaveResult saveLoadedFeature() { + if (blocked()) { + return WorkbenchSaveResult.cancelled("Save is locked while an agent holds control."); + } WorkbenchSavePreview preview = controller.savePreview(); if (!preview.savable()) { showFailure("Could not save", new IllegalStateException(preview.summary())); - return; + return WorkbenchSaveResult.unsavable(preview.summary()); } int choice = JOptionPane.showConfirmDialog( this, @@ -1023,7 +1020,7 @@ private void saveLoadedFeature() { ); if (choice != JOptionPane.OK_OPTION) { updatePlayerView("Save cancelled. The original feature file was not changed."); - return; + return WorkbenchSaveResult.cancelled("Save cancelled. The original feature file was not changed."); } try { WorkbenchSaveResult result = controller.commitSave(); @@ -1032,8 +1029,10 @@ private void saveLoadedFeature() { } else { showFailure("Could not save", new IllegalStateException(result.message())); } + return result; } catch (RuntimeException failure) { showFailure("Could not save feature file", failure); + return WorkbenchSaveResult.unsavable(failure.getMessage()); } } @@ -1400,43 +1399,122 @@ public void changedUpdate(DocumentEvent event) { } private void wirePlayerActions() { - playButton.addActionListener(event -> runScenarioFromBeginning()); + playButton.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.play(); + }); pauseButton.addActionListener(event -> { if (humanControlsLocked()) return; - player.pause(); - updatePlayerView(playbackBusy - ? "Pause requested; the current step will finish first." - : "Scenario playback paused."); + actions.pause(); }); playerStopButton.addActionListener(event -> { if (humanControlsLocked()) return; - player.stop(); - pendingFreshRun = false; - pendingFreshRunStepId = null; - pendingIsolatedStep = null; - updatePlayerView(playbackBusy - ? "Scenario playback stopped; the current step will finish but no next step will start." - : "Scenario playback stopped."); + actions.stopPlayback(); + }); + stepOnlyButton.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.executeStep(stepText.getText()); + }); + fromHereButton.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.fromHere(null); }); - stepOnlyButton.addActionListener(event -> executeStepOnly()); - fromHereButton.addActionListener(event -> runScenarioFromSelectedStep()); } private void wireSessionActions() { - syncItem.addActionListener(event -> - runStateAction("Synchronizing project", controller::synchronize)); - refreshItem.addActionListener(event -> - runStateAction("Refreshing status", controller::refresh)); - startItem.addActionListener(event -> - runStateAction("Starting worker", controller::startWorker)); - restartItem.addActionListener(event -> - runStateAction("Restarting worker", controller::restartWorker)); + syncItem.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.sessionSync(); + }); + refreshItem.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.refresh(); + }); + startItem.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.workerStart(); + }); + restartItem.addActionListener(event -> { + if (humanControlsLocked()) return; + actions.workerRestart(); + }); stopItem.addActionListener(event -> { - player.stop(); - runStateAction("Stopping worker", controller::stopWorker); + if (humanControlsLocked()) return; + actions.workerStop(); }); } + private boolean blocked() { + return !commandDriven.get() && humanControlsLocked(); + } + + private void commandPause() { + if (blocked()) return; + player.pause(); + updatePlayerView(playbackBusy + ? "Pause requested; the current step will finish first." + : "Scenario playback paused."); + } + + private void commandStopPlayback() { + if (blocked()) return; + player.stop(); + pendingFreshRun = false; + pendingFreshRunStepId = null; + pendingIsolatedStep = null; + updatePlayerView(playbackBusy + ? "Scenario playback stopped; the current step will finish but no next step will start." + : "Scenario playback stopped."); + } + + private void commandPlayFromHere(String stepId) { + if (stepId != null && !stepId.isBlank()) { + try { + player.clickLine(Long.parseLong(stepId.strip())); + } catch (RuntimeException failure) { + showFailure("Could not run from selected step", failure); + return; + } + } + runScenarioFromSelectedStep(); + } + + private void openResolved(WorkbenchSessionActions.Opened opened) { + flushActiveEditorTab(); + controller.loadPickerScenario( + opened.lines(), + opened.file(), + opened.name(), + opened.startLine(), + opened.endLine(), + opened.exampleRow(), + opened.exampleLabel(), + opened.exampleSelector() + ); + int row = playback.origin().exampleRow(); + picker.setSaveEnabled(opened.file() != null); + openEditorTab( + opened.file(), + opened.name(), + opened.name(), + opened.startLine(), + opened.endLine(), + row, + playback.origin().exampleLabel(), + player.documentText(), + true + ); + syncScenarioView(); + updatePlayerView("Loaded " + opened.name() + "."); + resolveSelectedStep(); + } + + private void commandSelectExample(String selector) { + controller.selectExample(selector); + updatePlayerView("Examples selector " + selector + " shows row " + playback.origin().exampleRow() + "."); + syncScenarioView(); + } + private void runScenarioFromBeginning() { requestFreshRun(null); } @@ -1454,7 +1532,7 @@ private void runScenarioFromSelectedStep() { } private void requestFreshRun(Long startStepId) { - if (humanControlsLocked()) return; + if (blocked()) return; pendingIsolatedStep = null; if (playbackBusy || playbackPreparing) { pendingFreshRun = true; @@ -1593,10 +1671,10 @@ private void schedulePlaybackStep() { ); } - private void insertStep() { - if (humanControlsLocked()) return; + private void insertStep(String text) { + if (blocked()) return; try { - LiveScenarioPlayer.Line inserted = player.insertStep(stepText.getText()); + LiveScenarioPlayer.Line inserted = player.insertStep(text); stepText.setText(""); player.clickLine(inserted.id()); syncScenarioView(); @@ -1618,10 +1696,10 @@ private void insertStep() { } } - private void updateSelectedStep() { - if (humanControlsLocked()) return; + private void updateSelectedStep(String text) { + if (blocked()) return; try { - LiveScenarioPlayer.Line updated = player.updateSelectedStep(stepText.getText()); + LiveScenarioPlayer.Line updated = player.updateSelectedStep(text); syncScenarioView(); showLine(updated.id()); updatePlayerView("Updated selected line in place."); @@ -1630,9 +1708,8 @@ private void updateSelectedStep() { } } - private void executeStepOnly() { - if (humanControlsLocked()) return; - String text = stepText.getText(); + private void executeStepOnly(String text) { + if (blocked()) return; if (text == null || text.isBlank()) { showFailure("Could not execute step", new IllegalArgumentException("Step Editor text must not be blank.")); @@ -2448,7 +2525,7 @@ private List linesAfter(LiveScenarioPlayer.Line selected) { } private void openSelectedTarget() { - if (humanControlsLocked()) return; + if (blocked()) return; LiveScenarioPlayer.Line selected = player.selectedLine().orElse(player.playheadLine().orElse(null)); if (selected == null) return; List refs = GherkinReference.parse(selected.text(), linesAfter(selected)); @@ -2467,48 +2544,15 @@ private void openSelectedTarget() { return; } CatalogTargetResolver.Target found = target.get(); - flushActiveEditorTab(); if (found.scenario() != null) { - List fileLines = readFeatureFile(found.file(), found.scenario().lines()); - controller.loadPickerScenario( - fileLines, - found.file(), - found.scenario().name(), - found.scenario().startLine(), - found.scenario().endLine(), - found.scenario().exampleRow(), - found.scenario().exampleLabel() - ); - openEditorTab( - found.file(), - found.scenario().displayLabel(), - found.scenario().name(), - found.scenario().startLine(), - found.scenario().endLine(), - found.scenario().exampleRow(), - found.scenario().exampleLabel(), - player.documentText(), - true - ); + String example = found.scenario().exampleRow() > 0 + ? Integer.toString(found.scenario().exampleRow()) + : ""; + actions.openScenario(found.file().toString(), found.scenario().name(), example); } else { - List fileLines = readFeatureFile(found.file(), List.of()); - controller.loadPickerScenario(fileLines, found.file(), found.file().getFileName().toString(), 1, fileLines.size()); - openEditorTab( - found.file(), - found.file().getFileName().toString(), - found.file().getFileName().toString(), - 1, - fileLines.size(), - 0, - "", - player.documentText(), - true - ); + actions.openScenario(found.file().toString(), found.file().getFileName().toString(), ""); } - picker.setSaveEnabled(true); - syncScenarioView(); updatePlayerView("Opened " + found.detail() + "."); - resolveSelectedStep(); } private void showFailure(String label, Throwable failure) { @@ -2775,6 +2819,99 @@ private static JButton smallPlayerButton(String text, String tooltip) { return button; } + private final class SessionWindow implements WorkbenchSessionActions.Window { + @Override + public void beginCommand() { + commandDriven.set(true); + } + + @Override + public void endCommand() { + commandDriven.set(false); + } + + @Override + public void openResolved(WorkbenchSessionActions.Opened opened) { + WorkbenchFrame.this.openResolved(opened); + } + + @Override + public void selectExample(String selector) { + commandSelectExample(selector); + } + + @Override + public void playFromStart() { + runScenarioFromBeginning(); + } + + @Override + public void playFromHere(String stepId) { + commandPlayFromHere(stepId); + } + + @Override + public void pause() { + commandPause(); + } + + @Override + public void stopPlayback() { + commandStopPlayback(); + } + + @Override + public void executeStep(String text) { + executeStepOnly(text); + } + + @Override + public void insertStep(String text) { + WorkbenchFrame.this.insertStep(text); + } + + @Override + public void updateStep(String text) { + updateSelectedStep(text); + } + + @Override + public void diagnosticRun(String runId) { + if (LIVE_ISOLATE_RUN.equals(runId)) showLiveIsolate(); + else showDiagnosticRun(runId); + } + + @Override + public WorkbenchSaveResult presentSave() { + return saveLoadedFeature(); + } + + @Override + public void syncProject() { + runStateAction("Synchronizing project", controller::synchronize); + } + + @Override + public void refreshStatus() { + runStateAction("Refreshing status", controller::refresh); + } + + @Override + public void startWorker() { + runStateAction("Starting worker", controller::startWorker); + } + + @Override + public void restartWorker() { + runStateAction("Restarting worker", controller::restartWorker); + } + + @Override + public void stopWorker() { + runStateAction("Stopping worker", controller::stopWorker); + } + } + private static String defaultOverrideSource() { return """ package tools.dscode.workbench.generated; 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 949f77591..8da476d7a 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 @@ -58,6 +58,10 @@ final class WorkbenchUiController implements AutoCloseable { this.services = services; } + WorkbenchServices services() { + return services; + } + State refresh() { try { manifest = services.synchronizationStatus(); @@ -314,10 +318,29 @@ void loadPickerScenario( String exampleLabel ) { services.loadPickerScenario( - lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel + lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel, "" + ); + } + + void loadPickerScenario( + java.util.List lines, + Path originFile, + String scenarioName, + int startLine, + int endLine, + int exampleRow, + String exampleLabel, + String exampleSelector + ) { + services.loadPickerScenario( + lines, originFile, scenarioName, startLine, endLine, exampleRow, exampleLabel, exampleSelector ); } + void selectExample(String selector) { + services.selectExample(selector); + } + void addLeaseListener(java.util.function.Consumer listener) { services.addLeaseListener(listener); } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java new file mode 100644 index 000000000..7daca8bd3 --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java @@ -0,0 +1,222 @@ +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.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkbenchSessionActionsTest { + @TempDir + Path project; + + @Test + void openScenarioExamplePlayAndDiagnosticRunShareOneCommandPath() throws Exception { + Path feature = project.resolve("src/test/resources/features/shop.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Shop + Scenario Outline: Buy + When buy + Examples: + | item | + | a | + | b | + | c | + Examples: + | item | + | d | + | e | + | f | + """); + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("session-test")); + WorkbenchSessionActions actions = new WorkbenchSessionActions(controller); + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> { + Map opened = actions.openScenario("shop.feature", "Buy", "2.2"); + assertEquals("SUCCESS", opened.get("status")); + assertEquals(5, controller.playback().origin().exampleRow()); + assertTrue(controller.playback().playPlan().steps().getFirst().executeText().contains("buy e")); + + Map first = actions.example("5 1"); + assertEquals("SUCCESS", first.get("status")); + assertEquals(1, controller.playback().origin().exampleRow()); + assertTrue(controller.playback().playPlan().steps().getFirst().executeText().contains("buy a")); + + Map paused = actions.pause(); + assertEquals("PAUSED", paused.get("playback")); + + Map played = actions.play(); + assertEquals("FAILED", played.get("status")); + + Map shown = actions.diagnosticRun("run-42"); + assertEquals("SUCCESS", shown.get("status")); + assertEquals("run-42", actions.selectedDiagnosticRun()); + + Map stopped = actions.dispatch("stop", Map.of()); + assertEquals("SUCCESS", stopped.get("status")); + assertEquals("STOPPED", stopped.get("playback")); + }); + IllegalArgumentException invalid = assertThrows( + IllegalArgumentException.class, + () -> actions.example("0") + ); + assertTrue(invalid.getMessage().contains("'0'")); + assertFalse(invalid.getMessage().startsWith("No scenarios matched")); + } + } + + @Test + void windowReceivesEverySessionCommand() throws Exception { + Path feature = project.resolve("src/test/resources/features/shop.feature"); + Files.createDirectories(feature.getParent()); + Files.writeString(feature, """ + Feature: Shop + Scenario: Buy + When buy a + """); + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchCallContext.runAs(WorkbenchLeaseHolder.AGENT, () -> controller.requestControl("session-test")); + WorkbenchSessionActions actions = new WorkbenchSessionActions(controller); + RecordingWindow window = new RecordingWindow(feature); + actions.installWindow(window); + List> calls = List.of( + Map.of("op", "open-scenario", "feature", "shop.feature", "name", "Buy", "example", "1"), + Map.of("op", "example", "example", "1"), + Map.of("op", "play"), + Map.of("op", "from-here", "fromHere", "1"), + Map.of("op", "pause"), + Map.of("op", "execute-step", "text", "When buy a"), + Map.of("op", "insert-step", "text", "Then stay"), + Map.of("op", "update-step", "text", "When buy a"), + Map.of("op", "diagnostic-run", "run", "run-42"), + Map.of("op", "save"), + Map.of("op", "refresh"), + Map.of("op", "session-sync"), + Map.of("op", "worker-start"), + Map.of("op", "worker-restart"), + Map.of("op", "worker-stop"), + Map.of("op", "stop") + ); + for (Map args : calls) { + String op = args.get("op"); + Map result = actions.dispatch(op, args); + assertEquals("SUCCESS", result.get("status"), op + " " + result); + assertEquals(op, result.get("op"), result.toString()); + assertTrue(window.calls.contains(op), window.calls.toString()); + } + assertEquals("WRITTEN", window.save.status()); + } + } + + private static final class RecordingWindow implements WorkbenchSessionActions.Window { + private final Path feature; + private final List calls = new ArrayList<>(); + private WorkbenchSaveResult save = WorkbenchSaveResult.cancelled("not called"); + + private RecordingWindow(Path feature) { + this.feature = feature; + } + + @Override + public void beginCommand() { + } + + @Override + public void endCommand() { + } + + @Override + public void openResolved(WorkbenchSessionActions.Opened opened) { + calls.add("open-scenario"); + } + + @Override + public void selectExample(String selector) { + calls.add("example"); + } + + @Override + public void playFromStart() { + calls.add("play"); + } + + @Override + public void playFromHere(String stepId) { + calls.add("from-here"); + } + + @Override + public void pause() { + calls.add("pause"); + } + + @Override + public void stopPlayback() { + calls.add("stop"); + } + + @Override + public void executeStep(String text) { + calls.add("execute-step"); + } + + @Override + public void insertStep(String text) { + calls.add("insert-step"); + } + + @Override + public void updateStep(String text) { + calls.add("update-step"); + } + + @Override + public void diagnosticRun(String runId) { + calls.add("diagnostic-run"); + } + + @Override + public WorkbenchSaveResult presentSave() { + calls.add("save"); + save = WorkbenchSaveResult.written(feature, "Buy"); + return save; + } + + @Override + public void syncProject() { + calls.add("session-sync"); + } + + @Override + public void refreshStatus() { + calls.add("refresh"); + } + + @Override + public void startWorker() { + calls.add("worker-start"); + } + + @Override + public void restartWorker() { + calls.add("worker-restart"); + } + + @Override + public void stopWorker() { + calls.add("worker-stop"); + } + } +} 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 index bd55cdf6a..33999bfe4 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchAttachServerTest.java @@ -145,4 +145,61 @@ void cliSessionWritesDedicatedStateFileAndQueuesExecuteStep() throws Exception { } assertTrue(Files.notExists(WorkbenchAttachServer.cliSessionStateFile(project))); } + + @Test + void uiAttachAcceptsTheSameSessionCommandsAndStopLeavesTheEndpointUp() throws Exception { + try (WorkbenchController controller = new WorkbenchController(project); + WorkbenchAttachServer server = WorkbenchAttachServer.start(controller, project)) { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + HttpResponse unknown = post(client, server, "{\"op\":\"not-a-command\"}"); + assertEquals(400, unknown.statusCode()); + + HttpResponse pause = post(client, server, "{\"op\":\"pause\",\"id\":\"pause-1\"}"); + assertEquals(200, pause.statusCode()); + JsonNode ack = JSON.readTree(pause.body()); + assertEquals("pause-1", ack.get("id").asText()); + + JsonNode done = await(client, server, "pause-1"); + assertEquals("SUCCESS", done.get("status").asText()); + + HttpResponse stop = post(client, server, "{\"op\":\"stop\"}"); + assertEquals(200, stop.statusCode()); + assertTrue(stop.body().contains("STOPPING")); + HttpResponse health = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/health")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(200, health.statusCode()); + } + } + + private static HttpResponse post(HttpClient client, WorkbenchAttachServer server, String body) throws Exception { + return client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/commands")) + .header("Authorization", "Bearer " + server.token()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private static JsonNode await(HttpClient client, WorkbenchAttachServer server, String id) throws Exception { + JsonNode done = null; + for (int i = 0; i < 50; i++) { + HttpResponse status = client.send( + HttpRequest.newBuilder(URI.create(server.url() + "/commands/" + id)) + .header("X-Workbench-Token", server.token()) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertEquals(200, status.statusCode()); + done = JSON.readTree(status.body()); + String value = done.get("status").asText(); + if ("SUCCESS".equals(value) || "FAILED".equals(value) || "TIMEOUT".equals(value)) return done; + Thread.sleep(20); + } + return done; + } } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchCommandQueueTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchCommandQueueTest.java index 516551f9a..71abd8eb7 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchCommandQueueTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/mcp/WorkbenchCommandQueueTest.java @@ -44,6 +44,20 @@ void ackThenDoneAndSerialExecuteStep() throws Exception { } } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void enqueueOpUsesTheSharedHandler() throws Exception { + try (WorkbenchCommandQueue queue = new WorkbenchCommandQueue(text -> { + throw new AssertionError("execute-step function is not used once an op handler is installed"); + })) { + queue.setOpHandler((op, args) -> Map.of("status", "SUCCESS", "op", op, "example", args.get("example"))); + Map ack = queue.enqueueOp("example", Map.of("example", "2.2"), "row"); + assertEquals("row", ack.get("id")); + assertEquals("SUCCESS", awaitStatus(queue, "row", "SUCCESS")); + assertEquals("2.2", ((Map) queue.status("row").get("result")).get("example")); + } + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void stillWorkingWhileSlowToolUntilHeartbeatIsSilenced() throws Exception { diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinPlayPlanTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinPlayPlanTest.java index 92b51112c..71e1555d7 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinPlayPlanTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/player/GherkinPlayPlanTest.java @@ -34,6 +34,37 @@ void prependsBackgroundAndSubstitutesSelectedOutlineRow() { assertTrue(plan.steps().get(1).sourceText().contains("")); } + @Test + void secondExamplesTableUsesTheSameSelectorAsPkbExample() { + LiveScenarioPlayer player = new LiveScenarioPlayer(List.of( + "Feature: Shop", + " Scenario Outline: Buy", + " When buy ", + " Examples:", + " | item |", + " | a |", + " | b |", + " | c |", + " Examples:", + " | item |", + " | d |", + " | e |", + " | f |" + )); + ScenarioOrigin byOverall = new ScenarioOrigin( + Path.of("shop.feature"), "Buy", 2, 14, 5, "e" + ); + ScenarioOrigin byTable = new ScenarioOrigin( + Path.of("shop.feature"), "Buy", 2, 14, 0, "", "2.2" + ); + assertEquals(" When buy e", GherkinPlayPlan.from(player, byOverall).steps().getFirst().executeText()); + assertEquals(" When buy e", GherkinPlayPlan.from(player, byTable).steps().getFirst().executeText()); + ScenarioOrigin firstOfList = new ScenarioOrigin( + Path.of("shop.feature"), "Buy", 2, 14, 0, "", "5 1" + ); + assertEquals(" When buy a", GherkinPlayPlan.from(player, firstOfList).steps().getFirst().executeText()); + } + @Test void demoBufferPlansEveryExecutableLine() { LiveScenarioPlayer player = LiveScenarioPlayer.interactiveBuffer(); diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java index 0ea2f8af3..8814df840 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java @@ -162,12 +162,45 @@ void detectsMavenAndGradleSelectedProjects() throws Exception { Path maven = tempDir.resolve("maven"); Files.createDirectories(maven); Files.writeString(maven.resolve("pom.xml"), ""); + Path mvnw = maven.resolve(WorkbenchProject.isWindows() ? "mvnw.cmd" : "mvnw"); + Files.writeString(mvnw, WorkbenchProject.isWindows() ? "@echo off\r\n" : "#!/bin/sh\n"); assertEquals(WorkbenchProject.Type.MAVEN, WorkbenchProject.locate(maven).type()); + assertEquals(mvnw, WorkbenchProject.locate(maven).launcher()); Path gradle = tempDir.resolve("gradle"); Files.createDirectories(gradle); Files.writeString(gradle.resolve("build.gradle"), "plugins { id 'java' }"); + Path gradlew = gradle.resolve(WorkbenchProject.isWindows() ? "gradlew.bat" : "gradlew"); + Files.writeString(gradlew, WorkbenchProject.isWindows() ? "@echo off\r\n" : "#!/bin/sh\n"); assertEquals(WorkbenchProject.Type.GRADLE, WorkbenchProject.locate(gradle).type()); + assertEquals(gradlew, WorkbenchProject.locate(gradle).launcher()); + } + + @Test + void wrapperJarIsUsedWhenTheScriptIsAbsent() throws Exception { + Path maven = tempDir.resolve("jar-maven"); + Files.createDirectories(maven.resolve(".mvn").resolve("wrapper")); + Files.writeString(maven.resolve("pom.xml"), ""); + Path jar = maven.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.jar"); + Files.writeString(jar, "jar"); + assertEquals(jar, WorkbenchProject.locate(maven).launcher()); + List command = WorkbenchSynchronizer.executableCommand(jar, List.of("test")); + assertEquals("-jar", command.get(1)); + assertEquals(jar.toString(), command.get(2)); + assertEquals("test", command.get(3)); + } + + @Test + void missingWrapperNamesTheProjectScript() throws Exception { + Path maven = tempDir.resolve("bare-maven"); + Files.createDirectories(maven); + Files.writeString(maven.resolve("pom.xml"), ""); + IllegalArgumentException failure = org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> WorkbenchProject.locate(maven) + ); + assertTrue(failure.getMessage().contains("mvnw")); + assertTrue(failure.getMessage().contains("machine-wide")); } @Test @@ -379,6 +412,8 @@ static MavenFixture create(Path projectRoot) throws Exception { Files.createDirectories(projectRoot.resolve("src/test/java")); Files.createDirectories(projectRoot.resolve("src/test/resources/features")); Files.writeString(projectRoot.resolve("pom.xml"), "", StandardCharsets.UTF_8); + Files.writeString(projectRoot.resolve("mvnw.cmd"), "@echo off\r\n", StandardCharsets.UTF_8); + Files.writeString(projectRoot.resolve("mvnw"), "#!/bin/sh\n", StandardCharsets.UTF_8); Files.writeString( projectRoot.resolve("src/test/java/Runner.java"), "class Runner {}\n", 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 e5cbdf545..9ffcd4b46 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 @@ -591,7 +591,7 @@ yield new ControlBridgeServiceCallResult( yield null; } case "addPlayerListener", "removePlayerListener", "loadPickerScenario", - "loadDefaultDemo", "replaceLiveDocument" -> null; + "loadDefaultDemo", "replaceLiveDocument", "selectExample" -> null; case "savePreview" -> WorkbenchSavePreview.unsavable("session-only"); case "requestSave", "commitSave" -> WorkbenchSaveResult.unsavable("session-only"); case "workerLogFiles" -> { diff --git a/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java b/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java index da3d9b03e..3f22cf079 100644 --- a/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java +++ b/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Locale; -/** Invokes the consumer Maven wrapper the same way {@code mvn test} would. */ +/** Invokes the consumer Maven wrapper. A machine-wide Maven install is not used. */ public final class ConsumerMavenTestRunner { private ConsumerMavenTestRunner() { } @@ -19,8 +19,7 @@ int run(List command, Path directory, PrintStream out, PrintStream err) } public static List command(Path projectRoot, String compactRunVars) { - List command = new ArrayList<>(); - command.add(wrapper(projectRoot).toString()); + List command = new ArrayList<>(launchPrefix(wrapper(projectRoot))); command.add("test"); command.add("-Dpkb_runvars=" + compactRunVars); command.add("-Dpkb_run_purpose=workbench-discover"); @@ -28,8 +27,7 @@ public static List command(Path projectRoot, String compactRunVars) { } public static List confirmCommand(Path projectRoot, String compactRunVars) { - List command = new ArrayList<>(); - command.add(wrapper(projectRoot).toString()); + List command = new ArrayList<>(launchPrefix(wrapper(projectRoot))); command.add("test"); command.add("-Dpkb_runvars=" + compactRunVars); command.add("-Dpkb_run_purpose=workbench-confirm"); @@ -71,7 +69,27 @@ static Path wrapper(Path projectRoot) { if (Files.isRegularFile(script)) return script; Path alternate = project.resolve(windows ? "mvnw" : "mvnw.cmd"); if (Files.isRegularFile(alternate)) return alternate; - return Path.of(windows ? "mvn.cmd" : "mvn"); + Path jar = project.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.jar"); + if (Files.isRegularFile(jar)) return jar; + throw new IllegalArgumentException( + "No Maven wrapper at " + project + + ". Expected mvnw or mvnw.cmd, or .mvn/wrapper/maven-wrapper.jar." + + " Pickleball does not use a machine-wide Maven install." + ); + } + + static List launchPrefix(Path launcher) { + String name = launcher.getFileName() == null ? launcher.toString() : launcher.getFileName().toString(); + if (name.endsWith(".jar")) { + return List.of(javaExecutable(), "-jar", launcher.toString()); + } + return List.of(launcher.toString()); + } + + private static String javaExecutable() { + boolean windows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + Path bin = Path.of(System.getProperty("java.home"), "bin", windows ? "java.exe" : "java"); + return Files.isRegularFile(bin) ? bin.toString() : (windows ? "java.exe" : "java"); } private static ProcessLauncher inheritIoLauncher() { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java index 4cec31569..54a997cfb 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java @@ -70,7 +70,7 @@ private static int hint(WorkbenchCommandLine.Parsed parsed, PrintStream out) { out.println(); printDryRunResolve(parsed.project(), plan.runVars(), false, out); out.println("Browser: " + plan.browser().browser() + " (" + plan.browser().reason() + ")."); - out.println("Multi-scenario Discover/Confirm use this high pkb_parallel. Live isolate starts a headless Workbench session; then use execute-step / status / events / stop. Same launcher, only change exec.args."); + out.println("Multi-scenario Discover/Confirm use this high pkb_parallel. When a Workbench window is already open, drive that session with open-scenario, example, play, execute-step, stop, and diagnostic-run so the person sees it. Otherwise isolate starts a headless session; then use execute-step / status / events / stop. Do not start the GUI. Same launcher, only change exec.args."); out.println("After Discover, confirm (and isolate/execute-step for live debug) replay the retained pkb_run_profile through pkb_runvars. Never supply pkb_run_profile as input."); out.println("Sealed runs are opt-in: resolve → inspect → complete map including the six context keys → pkb_overriderunvars. Do not mix with pkb_runvars or pkb_profile. Compare runProfileFingerprint after the sealed run."); out.println(); @@ -181,7 +181,7 @@ private static int printCatalog( out.println("run-catalog.json: " + latest.catalog()); out.println("retained pkb_run_profile: " + latest.runProfile()); if (writeSnapshot) { - out.println("NEXT: confirm --tags/--name/--example. For live debug: isolate (starts session), then execute-step / status / events / stop."); + out.println("NEXT: confirm --tags/--name/--example. When a Workbench window is already open, drive that session with open-scenario, example, play, execute-step, stop, and diagnostic-run. Otherwise isolate, then execute-step / status / events / stop. Do not start the GUI."); } return mavenExit; } catch (Exception failure) { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java index 4045ab86c..5ef4c8f38 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java @@ -18,7 +18,10 @@ final class WorkbenchCommandLine { "export-guidance", "hint", "discover-hint", "discover", "confirm", "resolve-runvars" ); static final Set SESSION_CLIENT_COMMANDS = Set.of( - "isolate", "session-start", "execute-step", "status", "events", "stop", "kill" + "isolate", "session-start", "execute-step", "status", "events", "stop", "kill", + "open-scenario", "example", "play", "from-here", "pause", + "insert-step", "update-step", "diagnostic-run", "save", "refresh", + "session-sync", "worker-start", "worker-restart", "worker-stop" ); private WorkbenchCommandLine() { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java index a81e13653..15ce04a52 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java @@ -70,6 +70,13 @@ static int run( return switch (parsed.command()) { case "isolate", "session-start" -> sessionStart(parsed, out, err, starter, http); case "execute-step" -> executeStep(parsed, flags, out, err, http); + case "insert-step", "update-step" -> textCommand(parsed, flags, out, err, http); + case "open-scenario" -> openScenario(parsed, flags, out, err, http); + case "example" -> example(parsed, flags, out, err, http); + case "diagnostic-run" -> diagnosticRun(parsed, flags, out, err, http); + case "play", "from-here", "pause", "save", "refresh", + "session-sync", "worker-start", "worker-restart", "worker-stop" -> + postCommand(parsed, flags, out, err, http); case "status" -> status(parsed, flags, out, err, http); case "events" -> events(parsed, out, err, http); case "stop", "kill" -> stop(parsed, out, err, http); @@ -183,10 +190,109 @@ private static int executeStep( err.println("Usage: execute-step or execute-step --text= [--ack-only]"); return 2; } - Map body = new LinkedHashMap<>(); - body.put("op", "execute-step"); + Map body = commandBody(parsed, flags); body.put("text", text); + return postAndMaybeWait(state, flags, body, out, err, http); + } + + private static int textCommand( + WorkbenchCommandLine.Parsed parsed, + SessionFlags flags, + PrintStream out, + PrintStream err, + HttpClient http + ) { + if (flags.text == null || flags.text.isBlank()) { + err.println("Usage: " + parsed.command() + " --text="); + return 2; + } + SessionState state = requireSession(parsed.project(), http); + return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); + } + + private static int openScenario( + WorkbenchCommandLine.Parsed parsed, + SessionFlags flags, + PrintStream out, + PrintStream err, + HttpClient http + ) { + boolean featureBlank = flags.feature == null || flags.feature.isBlank(); + boolean nameBlank = parsed.name() == null || parsed.name().isBlank(); + if (featureBlank && nameBlank) { + err.println("Usage: open-scenario --feature= --name= [--example=]"); + return 2; + } + SessionState state = requireSession(parsed.project(), http); + return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); + } + + private static int example( + WorkbenchCommandLine.Parsed parsed, + SessionFlags flags, + PrintStream out, + PrintStream err, + HttpClient http + ) { + String selector = parsed.example(); + if (selector == null || selector.isBlank()) selector = flags.text; + if (selector == null || selector.isBlank()) { + err.println("Usage: example --example="); + return 2; + } + SessionState state = requireSession(parsed.project(), http); + Map body = commandBody(parsed, flags); + body.put("example", selector); + return postAndMaybeWait(state, flags, body, out, err, http); + } + + private static int diagnosticRun( + WorkbenchCommandLine.Parsed parsed, + SessionFlags flags, + PrintStream out, + PrintStream err, + HttpClient http + ) { + if (flags.run == null || flags.run.isBlank()) { + err.println("Usage: diagnostic-run --run="); + return 2; + } + SessionState state = requireSession(parsed.project(), http); + return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); + } + + private static int postCommand( + WorkbenchCommandLine.Parsed parsed, + SessionFlags flags, + PrintStream out, + PrintStream err, + HttpClient http + ) { + SessionState state = requireSession(parsed.project(), http); + return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); + } + + private static Map commandBody(WorkbenchCommandLine.Parsed parsed, SessionFlags flags) { + Map body = new LinkedHashMap<>(); + body.put("op", parsed.command()); + if (flags.text != null && !flags.text.isBlank()) body.put("text", flags.text); if (flags.id != null && !flags.id.isBlank()) body.put("id", flags.id); + if (flags.feature != null && !flags.feature.isBlank()) body.put("feature", flags.feature); + if (parsed.name() != null && !parsed.name().isBlank()) body.put("name", parsed.name()); + if (parsed.example() != null && !parsed.example().isBlank()) body.put("example", parsed.example()); + if (flags.run != null && !flags.run.isBlank()) body.put("run", flags.run); + if (flags.fromHere != null && !flags.fromHere.isBlank()) body.put("fromHere", flags.fromHere); + return body; + } + + private static int postAndMaybeWait( + SessionState state, + SessionFlags flags, + Map body, + PrintStream out, + PrintStream err, + HttpClient http + ) { JsonNode ack = postJson(http, state, "/commands", body); String id = textOr(ack, "id", ""); String status = textOr(ack, "status", "QUEUED"); @@ -204,7 +310,7 @@ private static int status( ) { Optional state = readHealthy(parsed.project(), http); if (state.isEmpty()) { - err.println("No healthy Workbench CLI session. Run isolate / session-start first."); + err.println("No healthy Workbench session. Run isolate / session-start, or use the window that is already open."); return 1; } String id = flags.id; @@ -254,10 +360,18 @@ private static int stop( return 0; } SessionState session = state.get(); + boolean watched = "ui-attach".equals(session.mode()); try { postJson(http, session, "/commands", Map.of("op", "stop")); - } catch (RuntimeException ignored) { - // Fall through to pid destroy. + } catch (RuntimeException failure) { + if (watched) { + err.println("Workbench stop failed: " + failure.getMessage()); + return 1; + } + } + if (watched) { + out.println("ACK SESSION playback-stopped pid=" + session.pid()); + return 0; } long deadline = System.nanoTime() + Duration.ofSeconds(8).toNanos(); while (System.nanoTime() < deadline) { @@ -324,12 +438,19 @@ private static int terminalExit(String status) { private static SessionState requireSession(Path project, HttpClient http) { return readHealthy(project, http).orElseThrow(() -> new IllegalStateException( - "No healthy Workbench CLI session. Run isolate / session-start first." + "No healthy Workbench session. Run isolate / session-start, or use the window that is already open." )); } static Optional readHealthy(Path project, HttpClient http) { - Path file = sessionFile(project); + Optional attached = readHealthyFile(PickleballLocalLayout.attachFile(project), project, http); + if (attached.isPresent() && "ui-attach".equals(attached.get().mode())) { + return attached; + } + return readHealthyFile(sessionFile(project), project, http); + } + + private static Optional readHealthyFile(Path file, Path project, HttpClient http) { if (!Files.isRegularFile(file)) return Optional.empty(); try { JsonNode root = JSON.readTree(file.toFile()); @@ -426,12 +547,26 @@ static SessionState from(Path project, JsonNode root) { static final class SessionFlags { final String text; final String id; + final String feature; + final String run; + final String fromHere; final boolean ackOnly; final boolean wait; - private SessionFlags(String text, String id, boolean ackOnly, boolean wait) { + private SessionFlags( + String text, + String id, + String feature, + String run, + String fromHere, + boolean ackOnly, + boolean wait + ) { this.text = text; this.id = id; + this.feature = feature; + this.run = run; + this.fromHere = fromHere; this.ackOnly = ackOnly; this.wait = wait; } @@ -439,10 +574,13 @@ private SessionFlags(String text, String id, boolean ackOnly, boolean wait) { static SessionFlags parse(String[] args) { String text = null; String id = null; + String feature = null; + String run = null; + String fromHere = null; boolean ackOnly = false; boolean wait = true; List words = new ArrayList<>(); - if (args == null) return new SessionFlags(null, null, false, true); + if (args == null) return new SessionFlags(null, null, null, null, null, false, true); for (int index = 1; index < args.length; index++) { String token = args[index]; if (token == null) continue; @@ -475,6 +613,30 @@ static SessionFlags parse(String[] args) { wait = false; continue; } + if (token.startsWith("--feature=")) { + feature = token.substring("--feature=".length()); + continue; + } + if ("--feature".equals(token) && index + 1 < args.length) { + feature = args[++index]; + continue; + } + if (token.startsWith("--run=")) { + run = token.substring("--run=".length()); + continue; + } + if ("--run".equals(token) && index + 1 < args.length) { + run = args[++index]; + continue; + } + if (token.startsWith("--from-here=")) { + fromHere = token.substring("--from-here=".length()); + continue; + } + if ("--from-here".equals(token) && index + 1 < args.length) { + fromHere = args[++index]; + continue; + } if (token.startsWith("--tags=") || token.startsWith("--name=")) continue; if (("--tags".equals(token) || "--name".equals(token)) && index + 1 < args.length) { index++; @@ -505,7 +667,7 @@ static SessionFlags parse(String[] args) { if ("status".equals(args[0]) && id == null && words.size() == 1) id = words.getFirst(); } } - return new SessionFlags(text, id, ackOnly, wait); + return new SessionFlags(text, id, feature, run, fromHere, ackOnly, wait); } private static boolean isSelectorTail(String token) { diff --git a/src/main/java/tools/dscode/testengine/ExampleRowFilter.java b/src/main/java/tools/dscode/testengine/ExampleRowFilter.java index fa377a6d6..3a66ea3b7 100644 --- a/src/main/java/tools/dscode/testengine/ExampleRowFilter.java +++ b/src/main/java/tools/dscode/testengine/ExampleRowFilter.java @@ -26,56 +26,35 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.regex.Matcher; import java.util.regex.Pattern; +import tools.dscode.control.protocol.ExampleRowSelector; + /** * Selects Examples rows after tag, name, and feature-name filters. * A normal scenario is table 1, row 1. */ public final class ExampleRowFilter { - private static final Pattern INTEGER = Pattern.compile("[1-9][0-9]*"); - private static final Pattern TABLE_ROW = Pattern.compile("([1-9][0-9]*)\\.([1-9][0-9]*)"); - private static final Pattern RANGE = Pattern.compile("([1-9][0-9]*)-([1-9][0-9]*)"); private static final String FEATURE_SEGMENT = "feature"; private static final String SCENARIO_SEGMENT = "scenario"; private static final String EXAMPLE_SEGMENT = "example"; - private final List clauses; + private final ExampleRowSelector selector; - private ExampleRowFilter(List clauses) { - this.clauses = List.copyOf(clauses); + private ExampleRowFilter(ExampleRowSelector selector) { + this.selector = selector; } public static boolean isInactive(String expression) { - return expression == null || expression.isBlank(); + return ExampleRowSelector.isInactive(expression); } public static ExampleRowFilter parse(String expression) { - if (isInactive(expression)) { - throw invalid(""); - } - String[] tokens = expression.trim().split("\\s+"); - List clauses = new ArrayList<>(); - for (String token : tokens) { - if (token == null || token.isEmpty()) { - throw invalid(""); - } - clauses.add(parseToken(token)); - } - if (clauses.isEmpty()) { - throw invalid(""); - } - return new ExampleRowFilter(clauses); + return new ExampleRowFilter(ExampleRowSelector.parse(expression)); } public boolean matches(int overall, int table, int row) { - for (Clause clause : clauses) { - if (clause.matches(overall, table, row)) { - return true; - } - } - return false; + return selector.matches(overall, table, row); } public static List filterPickles(String expression, List pickles) { @@ -302,77 +281,12 @@ private static String segment(UniqueId uniqueId, String type) { return value; } - private static Clause parseToken(String token) { - if (INTEGER.matcher(token).matches()) { - return new Overall(parsePositive(token)); - } - Matcher table = TABLE_ROW.matcher(token); - if (table.matches()) { - return new TableRowClause(parsePositive(table.group(1)), parsePositive(table.group(2))); - } - Matcher range = RANGE.matcher(token); - if (range.matches()) { - int low = parsePositive(range.group(1)); - int high = parsePositive(range.group(2)); - if (low > high) { - throw invalid(token); - } - return new RangeClause(low, high); - } - throw invalid(token); - } - - private static int parsePositive(String token) { - try { - int value = Integer.parseInt(token); - if (value <= 0) { - throw invalid(token); - } - return value; - } catch (NumberFormatException exception) { - throw invalid(token); - } - } - - private static IllegalArgumentException invalid(String token) { - return new IllegalArgumentException( - "Invalid pkb_example token '" + token + "'. " - + "Expected a positive integer, table.row, or low-high range of positive integers " - + "with no leading zeros." - ); - } - private static void appendDetail(StringBuilder message, String label, String value) { if (value != null && !value.isBlank()) { message.append(' ').append(label).append("=[").append(value.trim()).append(']'); } } - private interface Clause { - boolean matches(int overall, int table, int row); - } - - private record Overall(int index) implements Clause { - @Override - public boolean matches(int overall, int table, int row) { - return overall == index; - } - } - - private record TableRowClause(int tableNumber, int rowInTable) implements Clause { - @Override - public boolean matches(int overall, int table, int row) { - return table == tableNumber && row == rowInTable; - } - } - - private record RangeClause(int low, int high) implements Clause { - @Override - public boolean matches(int overall, int table, int row) { - return overall >= low && overall <= high; - } - } - record Coordinates(String uri, String scenarioId, int overall, int table, int row) { String identity() { return uri + "|" + scenarioId + "|" + overall; diff --git a/src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java b/src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java new file mode 100644 index 000000000..db70f3299 --- /dev/null +++ b/src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java @@ -0,0 +1,44 @@ +package tools.dscode.common.reporting.diagnostic; + +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 java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConsumerMavenTestRunnerTest { + @TempDir + Path project; + + @Test + void wrapperPrefersTheOsScriptThenTheWrapperJar() throws Exception { + boolean windows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + Path script = project.resolve(windows ? "mvnw.cmd" : "mvnw"); + Files.writeString(script, windows ? "@echo off\r\n" : "#!/bin/sh\n"); + List command = ConsumerMavenTestRunner.command(project, "pkb_tags=@one"); + assertEquals(script.toString(), command.getFirst()); + assertTrue(command.contains("test")); + + Files.delete(script); + Path jar = project.resolve(".mvn/wrapper/maven-wrapper.jar"); + Files.createDirectories(jar.getParent()); + Files.writeString(jar, "jar"); + List jarCommand = ConsumerMavenTestRunner.command(project, "pkb_tags=@one"); + assertEquals("-jar", jarCommand.get(1)); + assertEquals(jar.toString(), jarCommand.get(2)); + + Files.delete(jar); + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> ConsumerMavenTestRunner.wrapper(project) + ); + assertTrue(failure.getMessage().contains("mvnw")); + assertTrue(failure.getMessage().contains("machine-wide")); + } +} diff --git a/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java index ee6362c6b..0ad4be7b9 100644 --- a/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java +++ b/src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java @@ -209,7 +209,17 @@ void argumentNormalizationAndSessionCommandsAreUnchanged() { assertTrue(WorkbenchCommandLine.isSessionClientCommand("events")); assertTrue(WorkbenchCommandLine.isSessionClientCommand("stop")); assertTrue(WorkbenchCommandLine.isSessionClientCommand("kill")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("open-scenario")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("example")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("play")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("from-here")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("pause")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("diagnostic-run")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("save")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("session-sync")); + assertTrue(WorkbenchCommandLine.isSessionClientCommand("worker-start")); assertFalse(WorkbenchCommandLine.isSessionClientCommand("session")); + assertFalse(WorkbenchCommandLine.isForwardedCommand("play")); assertFalse(WorkbenchCommandLine.isSessionClientCommand("hint")); assertFalse(WorkbenchCommandLine.isAgentCoreCommand("isolate")); diff --git a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java index a21ae148d..c66d7927f 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java @@ -11,6 +11,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -76,6 +77,7 @@ void exportGuidanceUsesJdkLocalStore() throws Exception { @Test void discoverWrapsMavenAndRecordsSnapshot() throws Exception { + writeProjectWrapper(); Path catalogDir = tempDir.resolve("reports/diagnostic-runs"); Files.createDirectories(catalogDir); List> captured = new ArrayList<>(); @@ -118,6 +120,7 @@ void discoverWrapsMavenAndRecordsSnapshot() throws Exception { @Test void discoverNextIsConfirmNotIsolateMavenExec() throws Exception { + writeProjectWrapper(); Path catalogDir = tempDir.resolve("reports/diagnostic-runs"); Files.createDirectories(catalogDir); ByteArrayOutputStream stdout = new ByteArrayOutputStream(); @@ -151,10 +154,15 @@ void discoverNextIsConfirmNotIsolateMavenExec() throws Exception { assertTrue(text.contains("NEXT: confirm")); assertTrue(text.contains("isolate")); assertTrue(text.contains("execute-step")); + assertTrue(text.contains("open-scenario")); + assertTrue(text.contains("--example")); + assertTrue(text.contains("Do not start the GUI")); + assertFalse(text.contains("only controls")); } @Test void discoverRetentionAllWritesAll() throws Exception { + writeProjectWrapper(); Path catalogDir = tempDir.resolve("reports/diagnostic-runs"); Files.createDirectories(catalogDir); List> captured = new ArrayList<>(); @@ -218,6 +226,12 @@ private Output run(String... args) { return new Output(exit, stdout.toString(StandardCharsets.UTF_8), stderr.toString(StandardCharsets.UTF_8)); } + private void writeProjectWrapper() throws Exception { + boolean windows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + Path script = tempDir.resolve(windows ? "mvnw.cmd" : "mvnw"); + Files.writeString(script, windows ? "@echo off\r\n" : "#!/bin/sh\n"); + } + private static void restoreProperty(String key, String previous) { if (previous == null) { System.clearProperty(key); diff --git a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java index 69fb9f46d..28616c10c 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java @@ -15,6 +15,8 @@ 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.Locale; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -74,6 +76,103 @@ void sessionStartDetachesAndDoesNotWaitForTheControllerProcess() throws Exceptio } } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void watchedWindowIsPreferredAndStopDoesNotKillIt() throws Exception { + try (FakeSession cli = FakeSession.start(project); + FakeSession ui = FakeSession.start(project, "ui-attach", PickleballLocalLayout.attachFile(project))) { + AtomicReference hit = new AtomicReference<>(); + ui.onCommand = body -> hit.set(body); + Output play = run( + new String[]{"play", project.toString(), "--ack-only"}, + (proj, tags, name, example, log) -> { + throw new AssertionError("play must not start a session"); + } + ); + assertEquals(0, play.exitCode, play.stderr + play.stdout); + assertTrue(play.stdout.contains("ACK ")); + assertTrue(hit.get() != null && hit.get().contains("\"op\":\"play\"")); + assertTrue(hit.get().contains(ui.url)); + + Process child = startLongLivedChild(); + ui.setPid(child.pid()); + Output stop = run( + new String[]{"stop", project.toString()}, + (proj, tags, name, example, log) -> { + throw new AssertionError("stop must not start a session"); + } + ); + assertEquals(0, stop.exitCode, stop.stderr + stop.stdout); + assertTrue(stop.stdout.contains("playback-stopped")); + assertTrue(child.isAlive()); + child.destroyForcibly(); + assertTrue(cli.pidAlive()); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void openScenarioPostsTheSharedCommandAndDoesNotStartASession() throws Exception { + try (FakeSession ignored = FakeSession.start(project)) { + AtomicReference hit = new AtomicReference<>(); + ignored.onCommand = hit::set; + Output output = run( + new String[]{ + "open-scenario", project.toString(), + "--feature=shop.feature", "--name=Buy", "--example=2.2", "--ack-only" + }, + (proj, tags, name, example, log) -> { + throw new AssertionError("open-scenario must not start a session"); + } + ); + assertEquals(0, output.exitCode, output.stderr + output.stdout); + assertTrue(output.stdout.contains("ACK ")); + assertTrue(hit.get().contains("\"op\":\"open-scenario\"")); + assertTrue(hit.get().contains("shop.feature")); + assertTrue(hit.get().contains("2.2")); + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void eachSessionCommandPostsToTheOpenSessionAndDoesNotStartOne() throws Exception { + String[][] commands = { + {"example", "--example=2.2"}, + {"play"}, + {"from-here", "--from-here=4"}, + {"pause"}, + {"insert-step", "--text=When stay"}, + {"update-step", "--text=When stay"}, + {"diagnostic-run", "--run=run-42"}, + {"save"}, + {"refresh"}, + {"session-sync"}, + {"worker-start"}, + {"worker-restart"}, + {"worker-stop"} + }; + try (FakeSession session = FakeSession.start(project)) { + for (String[] command : commands) { + AtomicReference hit = new AtomicReference<>(); + session.onCommand = hit::set; + List args = new ArrayList<>(); + args.add(command[0]); + args.add(project.toString()); + for (int index = 1; index < command.length; index++) args.add(command[index]); + args.add("--ack-only"); + Output output = run( + args.toArray(String[]::new), + (proj, tags, name, example, log) -> { + throw new AssertionError(command[0] + " must not start a session"); + } + ); + assertEquals(0, output.exitCode, command[0] + " " + output.stderr + output.stdout); + assertTrue(output.stdout.contains("ACK "), command[0]); + assertTrue(hit.get() != null && hit.get().contains("\"op\":\"" + command[0] + "\""), hit.get()); + } + } + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void executeStepAckOnlyExitsWithoutWaitingForDone() throws Exception { @@ -119,38 +218,70 @@ private static Process startLongLivedChild() throws IOException { private static final class FakeSession implements AutoCloseable { private final HttpServer http; private final Path stateFile; + private final String url; private Process child; + private volatile java.util.function.Consumer onCommand; + private volatile long pid = ProcessHandle.current().pid(); - private FakeSession(HttpServer http, Path stateFile) { + private FakeSession(HttpServer http, Path stateFile, String url) { this.http = http; this.stateFile = stateFile; + this.url = url; } static FakeSession start(Path project) throws IOException { + return start(project, "cli-session", PickleballLocalLayout.cliSessionState(project)); + } + + static FakeSession start(Path project, String mode, Path stateFile) throws IOException { HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + FakeSession[] box = new FakeSession[1]; http.createContext("/health", exchange -> write(exchange, 200, "{\"status\":\"ok\"}")); http.createContext("/commands", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + FakeSession session = box[0]; + if (session != null && session.onCommand != null) { + session.onCommand.accept(body + " " + session.url); + } if ("POST".equals(exchange.getRequestMethod())) { - exchange.getRequestBody().readAllBytes(); write(exchange, 200, "{\"ack\":true,\"id\":\"step-held\",\"status\":\"QUEUED\"}"); return; } write(exchange, 200, "{\"id\":\"step-held\",\"status\":\"STILL_WORKING\"}"); }); http.start(); - Path stateFile = PickleballLocalLayout.cliSessionState(project); Files.createDirectories(stateFile.getParent()); String url = "http://127.0.0.1:" + http.getAddress().getPort(); + FakeSession session = new FakeSession(http, stateFile, url); + box[0] = session; + session.writeState(project, mode); + return session; + } + + void setPid(long pid) { + this.pid = pid; + try { + String text = Files.readString(stateFile).replaceAll("\"pid\"\\s*:\\s*\\d+", "\"pid\": " + pid); + Files.writeString(stateFile, text); + } catch (IOException failure) { + throw new IllegalStateException(failure); + } + } + + boolean pidAlive() { + return ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + } + + private void writeState(Path project, String mode) throws IOException { Files.writeString(stateFile, """ { "url": "%s", "token": "test-token", "pid": %d, "project": "%s", - "mode": "cli-session" + "mode": "%s" } - """.formatted(url, ProcessHandle.current().pid(), jsonEscape(project.toString()))); - return new FakeSession(http, stateFile); + """.formatted(url, pid, jsonEscape(project.toString()), mode)); } @Override From 480a2897c16f4996331a1963d65910f0d2e06bce Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 09:30:58 -0700 Subject: [PATCH 04/23] Bottom line: agents drive an open Workbench window through the project wrapper. When a window is already open, the same session commands update it. When nobody is watching, discover, confirm, and isolate stay headless. Agents run mvnw, mvnw.cmd, gradlew, gradlew.bat, or java -jar on the wrapper jar. --- AGENTS.md | 2 + docs/agent/README.md | 2 +- docs/agent/feature-map.md | 10 ++-- docs/agent/repository-index.md | 5 ++ docs/ai-run-configuration.md | 6 +- docs/consumer-agent-guide.md | 34 +++++++----- docs/consumer-project.md | 40 +++++++------- docs/getting-started.md | 22 +++++--- docs/pickleball-workbench-player.md | 2 +- docs/pickleball-workbench.md | 55 +++++++++++++++---- .../.github/copilot-instructions.md | 8 ++- maven-consumer-project/AGENTS.md | 8 ++- .../diagnostic/PickleballGuidanceChecks.java | 6 ++ pickleball-workbench/AGENTS.md | 6 +- .../pickleball/guidance/AGENT-GUIDE.md | 34 +++++++----- .../pickleball/guidance/docs/agent/README.md | 2 +- .../guidance/docs/agent/feature-map.md | 10 ++-- .../guidance/docs/agent/repository-index.md | 5 ++ .../guidance/docs/ai-run-configuration.md | 6 +- .../guidance/docs/consumer-agent-guide.md | 34 +++++++----- .../guidance/docs/consumer-project.md | 40 +++++++------- .../guidance/docs/getting-started.md | 22 +++++--- .../docs/pickleball-workbench-player.md | 2 +- .../guidance/docs/pickleball-workbench.md | 55 +++++++++++++++---- .../WorkbenchPointerContractTest.java | 38 +++++++++++++ 25 files changed, 303 insertions(+), 151 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 324ea9c4d..29f7ef8be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,6 +183,8 @@ Only the separate consumer worker JVM executes Pickleball. `WorkbenchWorkerManag The published Pickleball JAR embeds the completed controller-only Workbench JAR as exactly one opaque payload at `META-INF/pickleball/workbench/pickleball-workbench.jar`. It must not flatten Workbench/MCP classes into the outer runtime, and the nested Workbench must not contain core. The ownership mnemonic is: **Pickleball may contain Workbench; Workbench must not contain Pickleball.** +The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not start the GUI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. + ## Build and validation Use Java 21. diff --git a/docs/agent/README.md b/docs/agent/README.md index 2e5824248..c7571bd82 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -18,7 +18,7 @@ This directory supports repository-native AI coding agents. It is not a runtime Agent adapters should remain small and point back to the canonical contract rather than copying the full project description. -The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use Workbench `hint` / `discover` / `confirm`, then `isolate` / `execute-step` / `status` / `events` / `stop` for live debug. Do not start the GUI. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. +The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. The nested `/maven-consumer-project/.github/copilot-instructions.md` is identical to `AGENTS.md` so Copilot Chat sees the same Workbench pointer. diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index b9426c14a..bd1e27c30 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,9 +6,9 @@ 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; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -18,7 +18,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | @@ -43,12 +43,12 @@ The standalone Workbench shadow JAR is controller-only and thin: Workbench class The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `PKB_CONTROL_BRIDGE_SESSION_ID`, `PKB_CONTROL_BRIDGE_TOKEN`, and `PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO`. Pickleball may also accept the prior `PKB_STUDIO_BRIDGE_*` names as deprecated compatibility input aliases. New Workbench code must use only the neutral names. -`WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. Input fingerprints of Java sources, resources, build files, and dependency artifacts decide skip vs resources-only vs full compile; the output fingerprint in `manifest.json` remains provenance, not a skip key. Sync always passes `-DskipTests`. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. Live Gherkin buffer edits do not require sync. +`WorkbenchSynchronizer` uses the selected project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar) to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath. It does not fall back to a machine-wide Maven or Gradle install. Discover and confirm use the same Maven wrapper rule. MCP and the launcher do not embed Maven or Gradle and do not expose a general `mvn` or `gradle` tool. Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. Input fingerprints of Java sources, resources, build files, and dependency artifacts decide skip vs resources-only vs full compile; the output fingerprint in `manifest.json` remains provenance, not a skip key. Sync always passes `-DskipTests`. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. Live Gherkin buffer edits do not require sync. `WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through the protocol-owned worker class-name contract, without linking the worker class or invoking Maven/Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. A `FAILED` `executeStep` result leaves that same paused worker available; it is not a worker-ending error. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door and launcher `isolate` / `execute-step` / `status` / `events` / `stop` for live debug against a detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` so a watcher can join a human GUI session instead of starting a second Workbench; that attach file is not the consumer-agent path. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. The agent does not start the GUI and does not click JavaFX or WebView. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not start the GUI to obtain it. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. 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 bbf44cbe5..82d488d93 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -405,6 +405,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` +- `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballArtifactLocatorTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java` @@ -504,6 +505,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ExampleRowSelector.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballArtifactLocator.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java` @@ -511,6 +513,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballVersion.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlBridgeStepResolutionTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlProtocolStepSeedTest.java` +- `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java` ## Pickleball Workbench module @@ -579,6 +582,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerStatus.java` @@ -623,6 +627,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `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/WorkbenchSessionActionsTest.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/ai-run-configuration.md b/docs/ai-run-configuration.md index 3bb897e95..747683a68 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -305,7 +305,7 @@ Example compact rerun: -Dpkb_changed_variables=pkb_browser ``` -For an agent's bounded confirmation `mvn test` (not `PickleballTests` human defaults of `pretty` / `@all`), include diagnostic evidence controls, headless Chrome, and high parallelism when more than one scenario will run: +For an agent's bounded confirmation, run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. This is not the `PickleballTests` human defaults of `pretty` / `@all`. Include diagnostic evidence controls, headless Chrome, and high parallelism when more than one scenario will run: ```text -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_parallel=auto, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" @@ -430,7 +430,7 @@ When operating in a consumer project: ## AI agents -The agent-facing entry is Pickleball Workbench (`hint`, `discover`, `confirm` to find failures; `isolate` / `execute-step` for live debug). Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not start the GUI. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. ```text pkb_browser= @@ -440,7 +440,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -plus the narrowest useful `pkb_tags` / `pkb_name`. An agent can also pass `pkb_example`, or `confirm` / `isolate --example=...`, to run one or more Examples rows without the GUI. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. Live isolate stays one paused scenario (`pkb_parallel=1`) on a headless CLI session started with Maven-exec `isolate`. +plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not start the GUI. After Discover, inspect `pkb_run_profile` from `run-catalog.json`, `run-index.json`, or `summary.json`. Confirm and live isolate replay that retained profile through `pkb_runvars` (LastDiscoverSnapshot) unless the snapshot is marked sealed, in which case the next worker launch uses `-Dpkb_overriderunvars=`. If there is no prior Discover snapshot, Workbench says so; it does not silently re-resolve from project defaults. The Workbench sealed-RunVars panel, if unused, changes no behavior; Apply writes a sealed snapshot for the **next** launch and does not mutate an in-flight worker. diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 323228902..ab11c6786 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -6,28 +6,32 @@ A consumer project may contain only a short `AGENTS.md` bridge. That bridge uses ## Tool chooser -Pickleball Workbench is the one front door. It is a Java/Maven program, not a GUI requirement. Do not start the GUI. +Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not start the GUI. Do not click the JavaFX or WebView UI. -From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use the same Maven exec wrapper as the project pointer; only change `-Dexec.args`: +Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. + +From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use that wrapper and the same exec invocation as the project pointer; only change `-Dexec.args`: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" ``` -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, and `stop` — change `exec.args` only. +Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. + +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. -1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. -2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name/example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. -3. **Live debug** — `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each Maven exec exits; the session stays up. +1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. +2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. +3. **Live debug** — When nobody is watching, `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. When a Workbench window is already open, do not start another session. Drive that session with the same commands so the person sees the result: `open-scenario`, `example`, `play`, `from-here`, `execute-step`, `pause`, `stop`, `diagnostic-run`, and the worker, save, and refresh commands. `example` is the same Examples-row filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every match. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each launcher exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. -`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. `ui` is a host/human command. Agents must not use `ui`. If `workbench_*` tools already exist they are the same session, not a setup step. +`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Agents must not start the GUI. If `workbench_*` tools already exist they are the same session, not a setup step. Do not copy consumer features into `.pickleball` as a sandbox. ### Live isolation loop -After Discover has found the failing scenario, `isolate` starts a headless Workbench session if one is not already healthy. Later Maven exec commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. +After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. 1. `-Dexec.args=isolate` (or `session-start`) — starts the session from the last Discover snapshot and prints `ACK SESSION ...`. 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. @@ -50,7 +54,7 @@ After Discover has found the failing scenario, `isolate` starts a headless Workb Keep first-read small. After a successful export: 1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags/name. For live debug use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. +2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. 3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. 4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. 5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. @@ -144,7 +148,7 @@ Never supply `pkb_run_profile` or `pkb_run_profile.` as input. They are ### Default AI test-launch rule -When you launch Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative input. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. +When you launch Pickleball tests and the intended execution settings are known, run the project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar) and use `pkb_runvars` as the authoritative input. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. For an agent's bounded confirmation (not the human runner defaults), include diagnostic evidence controls, the browser ladder (keep a remote `pkb_browser`; otherwise prefer `CHROME_HEADLESS`), and high parallelism when more than one scenario will run. Documented AI Discover/Confirm `pkb_runvars` keys: @@ -161,7 +165,7 @@ Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failu These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario'" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario'" ``` After any diagnostic run, read `pkb_run_profile` from the pack. That is the complete resolved RunVar list, including inherited execution-context paths and the integer parallel count. Do not treat omitted `pkb_runvars` keys as equal to project `pickleball.properties`. Confirm stays on `pkb_runvars`. Live isolate/worker launch replays a sealed snapshot as `-Dpkb_overriderunvars=` and an ordinary snapshot as `-Dpkb_runvars=`; neither silently re-resolves from project defaults, and neither supplies `pkb_run_profile` as input. @@ -288,10 +292,10 @@ Agent git suspects are a local procedure against the consumer repo (`git log -n ## Diagnostic utility commands -The agent-facing name is Workbench. From a Maven consumer where Pickleball is on the test classpath, use the same launcher as the project pointer and only change `-Dexec.args`: +The agent-facing name is Workbench. From a consumer project where Pickleball is on the test classpath, use the project wrapper (`mvnw` or `mvnw.cmd`) and the same launcher as the project pointer. Only change `-Dexec.args`. Do not require a machine-wide Maven or Gradle install. ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" "-Dexec.args=hint" "-Dexec.args=discover [--tags ] [--name ] [--example ]" "-Dexec.args=confirm [--tags ] [--name ] [--example ]" @@ -391,7 +395,7 @@ Pickleball's example Maven consumer includes an opt-in mixed pass/fail suite tag Run it explicitly: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover --tags=@agent-pointer-eval" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover --tags=@agent-pointer-eval" ``` or `-Dpkb_runvars="pkb_tags=@agent-pointer-eval, pkb_browser=CHROME_HEADLESS"`. Do not add this tag to consumer `AGENTS.md` or Copilot pointer files. diff --git a/docs/consumer-project.md b/docs/consumer-project.md index 2da793751..70183a777 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -9,9 +9,11 @@ The nested consumer intentionally keeps its own Markdown minimal. Detailed usage From a Maven consumer with Pickleball on the test classpath: ```powershell -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" ``` +On other systems the same arguments go to `./mvnw`. Do not require a machine-wide Maven install. + Then read or browse: ```text @@ -28,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures, then `isolate` / `execute-step` for live debug. Do not start the GUI. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot @@ -58,15 +60,9 @@ Its runner starts a loopback-only local test site so scenarios can exercise Sele ## Requirements and run - JDK 21 -- Maven 3.9 or newer +- the project's Maven wrapper: `mvnw`, `mvnw.cmd`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar`. Pick the script from the OS. Do not require a machine-wide Maven install - Chrome available to Selenium, or another configured browser -```bash -mvn test -``` - -or use the included wrappers: - ```bash ./mvnw test ``` @@ -92,7 +88,7 @@ The test-scoped Pickleball dependency already contains its controller-only Workb .\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" ``` -Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Agents for this release should not use the GUI, `ui .`, or `attach.json` as their path. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. +Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Agents do not start the GUI and do not click it. When a person already has the window open, the same launcher commands drive that session so the person sees the result. When nobody is watching, `discover`, `confirm`, and `isolate` stay headless. Do not start the GUI in order to read `attach.json`. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. Runner defaults include: @@ -150,9 +146,9 @@ Common suite tags include `@all`, `@regression`, `@smoke`, `@browser`, and `@dat Controller/protocol migration checks must remain focused: use `@control-bridge` and/or `@step-override-bridge`, set `pkb_parallel=80` when practical, and do not run `@all` for Workbench isolation work. ```bash -mvn test -Dpkb_tags="@forms and @state-assertions" -mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" -mvn test "-Dpkb_example=1 2 5 3.4 7-11" +./mvnw test -Dpkb_tags="@forms and @state-assertions" +./mvnw test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" +./mvnw test "-Dpkb_example=1 2 5 3.4 7-11" ``` `pkb_example` runs after `pkb_tags` and `pkb_name`. It keeps Examples rows. It is not a tag. A normal Scenario counts as row 1. The same selector is `--example` on Workbench `discover`, `confirm`, and `isolate`. @@ -160,19 +156,21 @@ mvn test "-Dpkb_example=1 2 5 3.4 7-11" Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a bounded confirmation should not reuse those defaults. Use Workbench `confirm`, or a separate `pkb_runvars` command that honors the browser ladder: ```bash -PickleballWorkbenchLauncher confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11' +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11'" ``` +Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. + After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. The consumer `pom.xml` also defines Maven profiles such as: ```bash -mvn test -Pall -mvn test -Psmoke -mvn test -Pforms -mvn test -Pworkflow -mvn test -Pcomponents +./mvnw test -Pall +./mvnw test -Psmoke +./mvnw test -Pforms +./mvnw test -Pworkflow +./mvnw test -Pcomponents ``` Feature files remain authoritative for exact scenario tags. @@ -184,7 +182,7 @@ Reusable profiles live in `profiles.yaml`; local property-level overrides for ma For deterministic automation or AI reruns, use `pkb_runvars` as direct input: ```bash -mvn test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" +./mvnw test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" ``` or expanded members: @@ -240,7 +238,7 @@ Do not recursively ingest an entire run. ### Diagnostic CLI ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=hint" +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=hint" "-Dexec.args=discover" "-Dexec.args=confirm --tags=@the-failing-tag --example=1.1" DiagnosticCli compare-runs [output-json] diff --git a/docs/getting-started.md b/docs/getting-started.md index cf6f66d5b..9f5f94355 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,7 +7,7 @@ A consumer normally needs the Pickleball test dependency and one runner extendin ## Requirements - Java 21 -- Maven 3.9 or newer, or an equivalent Gradle setup +- the project's wrapper: `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install - a Selenium-supported browser for browser scenarios ## Maven dependency @@ -80,7 +80,11 @@ Named profile definitions use the same shared/local idea: define shared profiles ## Run ```bash -mvn test +./mvnw test +``` + +```powershell +.\mvnw.cmd test ``` ## Launch the matching Workbench @@ -88,12 +92,16 @@ mvn test The Pickleball dependency carries its version-matched, controller-only Workbench as an opaque nested thin JAR. Launch it from the consumer test classpath, or `java -jar` the published Pickleball artifact; do not add or version a second Workbench dependency: ```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ "-Dexec.args=ui ." ``` +```powershell +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +``` + ```bash java -jar ~/.m2/repository/tools/dscode/pickleball//pickleball-.jar ui . ``` @@ -103,7 +111,7 @@ After `export-guidance`, `.pickleball/open/pickleball-workbench.sh` (`.cmd` / `. Filter normally with RunVars such as: ```bash -mvn test "-Dpkb_tags=@forms and not @dialogs" +./mvnw test "-Dpkb_tags=@forms and not @dialogs" ``` ## Named profiles @@ -116,13 +124,13 @@ qa: ``` ```bash -mvn test -Dpkb_profile=qa +./mvnw test -Dpkb_profile=qa ``` Multiple profile names compose left-to-right: ```bash -mvn test -Dpkb_profile=qa,browser_firefox +./mvnw test -Dpkb_profile=qa,browser_firefox ``` A named profile automatically receives missing project execution-context RunVars (`pkb_glue`, `pkb_features`, `pkb_datapath`, `pkb_callpath`, `pkb_componentpath`, and `pkb_configpath`) when those values exist in normal project configuration. Optional RunVars do not implicitly inherit. @@ -132,7 +140,7 @@ A named profile automatically receives missing project execution-context RunVars For automation or an AI agent, use `pkb_runvars` as the direct input: ```bash -mvn test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" +./mvnw test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" ``` Project wiring omitted from a controlled input inherits only the six execution-context RunVars listed above. This lets a controlled rerun specify what changes without copying repetitive glue/resource wiring. diff --git a/docs/pickleball-workbench-player.md b/docs/pickleball-workbench-player.md index e6db5aba4..67f16a6e1 100644 --- a/docs/pickleball-workbench-player.md +++ b/docs/pickleball-workbench-player.md @@ -33,7 +33,7 @@ Feature-file selection is secondary and collapsed behind **Filter by feature**. ## Live Scenario Editor -The center editor is one ordinary Gherkin text document over `LiveScenarioPlayer`. Tab at the start of a line, or while only leading colons/spaces sit before the caret, inserts one extra Pickleball leading `:`; Shift-Tab removes one leading `:` if present. Mid-line Tab inserts a space and does not jump to another Swing control. Typing the first letters of a Gherkin keyword after optional leading colons/whitespace offers completion (Feature, Rule, Background, Scenario, Scenario Outline, Examples, Given, When, Then, And, But, `*`, IF, ELSE, ELSE-IF). Tab or Enter accepts the selected keyword and inserts a trailing space. When the completion popup is open, Tab accepts it; otherwise Tab at the indent prefix inserts `:`. Play, Step, and From Here keep using `LiveScenarioPlayer`. **Play** executes a derived plan: Background steps plus the selected scenario, with one Examples row substituted when selected. Clicking a line instantly seeks the playhead. The top player bar shows the Pickleball version. +The center editor is one ordinary Gherkin text document over `LiveScenarioPlayer`. Tab at the start of a line, or while only leading colons/spaces sit before the caret, inserts one extra Pickleball leading `:`; Shift-Tab removes one leading `:` if present. Mid-line Tab inserts a space and does not jump to another Swing control. Typing the first letters of a Gherkin keyword after optional leading colons/whitespace offers completion (Feature, Rule, Background, Scenario, Scenario Outline, Examples, Given, When, Then, And, But, `*`, IF, ELSE, ELSE-IF). Tab or Enter accepts the selected keyword and inserts a trailing space. When the completion popup is open, Tab accepts it; otherwise Tab at the indent prefix inserts `:`. Play, Step, and From Here keep using `LiveScenarioPlayer`. **Play** executes a derived plan: Background steps plus the selected scenario, with one Examples row substituted when selected. That row is the same selector as the `example` command and `pkb_example`. The Play button calls `play` and shows the first source-order match. Headless `pkb_example` still runs every match. Clicking a line instantly seeks the playhead. The top player bar shows the Pickleball version. Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so Mapping and Diagnostic explorer stay Workbench-only Maven dependencies resolved onto the forked controller classpath. JDK 21 does not ship a modern browser component. If JavaFX cannot start, those panels use their text fallbacks. The live editor is always the Gherkin text buffer. The JavaFX unnamed-module warning on Windows is expected and is not a hang; the player window is shown before WebView starts. diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 0131b7178..0d9b9354d 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -45,15 +45,21 @@ META-INF/pickleball/workbench/pickleball-workbench.jar Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version. -Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures, then `isolate` / `execute-step` / `status` / `events` / `stop` for live debug. Do not start the GUI. +Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures. When a Workbench window is already open, the same session commands (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the rest of the table below) drive that session so the person sees the result. When nobody is watching, `isolate` / `execute-step` / `status` / `events` / `stop` stay the headless path. Do not start the GUI. Do not click the JavaFX or WebView UI. + +Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Do not require a machine-wide Maven or Gradle install. Discover, confirm, and sync invoke that wrapper themselves. MCP and the launcher stay Pickleball actions. There is no general `mvn` or `gradle` tool. ```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ "-Dexec.args=export-guidance .pickleball" ``` +```powershell +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" +``` + ```bash "-Dexec.args=hint" "-Dexec.args=discover --tags=@smoke" @@ -64,9 +70,36 @@ mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ "-Dexec.args=execute-step --text='Given stay'" ``` -Same launcher; only change `-Dexec.args`. `isolate` starts a detached headless session; later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. +Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. + +`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not start the GUI. + +## The window is a client + +Every action that changes the open session already exists as a launcher command or a command-queue command. The Swing window calls that command. It does not keep a second implementation. + +| Window action | Command | +| --- | --- | +| Open a scenario | `open-scenario` (`--feature`, `--name`, optional `--example`) | +| Choose Examples rows | `example` — the same filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every matching row. | +| Play | `play` | +| From here | `from-here` | +| Step | `execute-step` | +| Pause | `pause` | +| Stop playback | `stop` | +| Insert a step | `insert-step` | +| Update the selected step | `update-step` | +| Save | `save` | +| Switch the retained diagnostic run | `diagnostic-run` (`--run`) | +| Refresh status | `refresh` | +| Synchronize the project | `session-sync` (the one-shot forwarded command remains `sync`) | +| Start, restart, or stop the worker | `worker-start`, `worker-restart`, `worker-stop` | + +Advanced inspector actions (breakpoints, Step Overrides, browser page and screenshot, service call, Mapping) already call `WorkbenchServices`, the same methods as the existing MCP tools. Take control, Allow, and Deny stay the existing lease tools. Do not add a pixel, WebView, or JavaFX click tool. + +When a Workbench window is already open, an agent uses these same commands against that session so the person sees the result. The agent does not start the GUI and does not click the JavaFX or WebView UI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. -`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. Play still picks one visible row in the Workbench player. Headless selection is `pkb_example`. Do not start the GUI. +Build tools stay the project wrapper. Discover, confirm, and `session-sync` invoke `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. MCP and the launcher do not embed Maven or Gradle and do not expose a general `mvn` or `gradle` tool. `hint`, `discover`, and `confirm` accept `--tags` / `--name` / `--example` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This retention overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. @@ -75,17 +108,17 @@ Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` pri `mcp` and `ui` remain host/human commands. Hosts that already run Workbench MCP can launch it from the consumer test classpath: ```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ "-Dexec.args=mcp ." ``` ```powershell -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Agents for this release should not use the GUI, `ui .`, or `.pickleball/workbench/attach.json` as their path; see `.pickleball/AGENT-GUIDE.md`. +Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Agents do not start the GUI and do not click it. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. @@ -326,9 +359,9 @@ The agent should update `currentAction` as it works. Playhead, Mapping, Terminal 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 +### Driving a session a person is watching -The Swing UI is a human player. Consumer AI agents for this release should not attach to a GUI. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. +The Swing window is a client of the session commands above. Consumer agents do not start that window and do not click the JavaFX or WebView UI. When a person already has it open, the agent runs those same launcher commands against that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. Those tools are the same session, not a second implementation and not a setup step. 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: @@ -359,7 +392,7 @@ A Copilot or other MCP-style client finds that file in the consumer project, the Headless `java -cp : tools.dscode.workbench.WorkbenchApplication mcp ` stays stdio JSON-RPC only. That is optional host wiring, not an agent setup step. The consumer-agent live path is launcher `isolate` then `execute-step`. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. -A human-watched UI session is optional and separate. From `maven-consumer-project`, a person may start `ui .` and then a watcher can join `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session, and do not treat that attach file as the default agent path. +A person may start `ui .` and watch the window. The agent then uses the session commands against that open session. Do not launch a second `mcp` process against the same live UI session, and do not start the GUI in order to obtain `.pickleball/workbench/attach.json`. ## MCP stdio @@ -369,7 +402,7 @@ Start the lightweight non-Spring MCP server for a consumer project. This is opti java -cp $workbenchCp tools.dscode.workbench.WorkbenchApplication mcp ".\maven-consumer-project" ``` -Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures and `isolate` / `execute-step` for live debug. Do not document or use the Swing GUI, `ui .`, or `.pickleball/workbench/attach.json` as the agent path. +Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."` through `mvnw` or `mvnw.cmd`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures. When a window is already open they drive it with the session commands. When nobody is watching they use `isolate` / `execute-step`. Do not start the GUI. Do not click the JavaFX or WebView UI. Do not require a machine-wide Maven or Gradle install. The server uses the official Java MCP SDK core and stdio transport with the Jackson 2 JSON adapter. MCP dependencies are Workbench-only and are resolved onto the forked controller classpath at launch; they are not shaded into the thin nested JAR. Workbench deliberately does not use Spring Boot, Spring Framework, Spring AI, WebMVC, or Tomcat. diff --git a/maven-consumer-project/.github/copilot-instructions.md b/maven-consumer-project/.github/copilot-instructions.md index 9c7799c1f..48591b8e7 100644 --- a/maven-consumer-project/.github/copilot-instructions.md +++ b/maven-consumer-project/.github/copilot-instructions.md @@ -1,5 +1,7 @@ -Before any Pickleball work, run `mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. +Before any Pickleball work, run the project wrapper (`mvnw.cmd` on Windows, otherwise `mvnw`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar`) as `org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. Do not require a machine-wide Maven or Gradle install. -Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. +Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. Do not click the JavaFX or WebView UI. -For live debug, Maven-exec `isolate` (starts a headless CLI session), then `execute-step`, `status`, `events`, and `stop`. If `workbench_*` tools are already attached they are the same session, not a setup step. +When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. + +Gradle projects use `gradlew.bat` or `gradlew` the same way. Pick the script from the OS. diff --git a/maven-consumer-project/AGENTS.md b/maven-consumer-project/AGENTS.md index 9c7799c1f..48591b8e7 100644 --- a/maven-consumer-project/AGENTS.md +++ b/maven-consumer-project/AGENTS.md @@ -1,5 +1,7 @@ -Before any Pickleball work, run `mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. +Before any Pickleball work, run the project wrapper (`mvnw.cmd` on Windows, otherwise `mvnw`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar`) as `org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. Do not require a machine-wide Maven or Gradle install. -Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. +Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. Do not click the JavaFX or WebView UI. -For live debug, Maven-exec `isolate` (starts a headless CLI session), then `execute-step`, `status`, `events`, and `stop`. If `workbench_*` tools are already attached they are the same session, not a setup step. +When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. + +Gradle projects use `gradlew.bat` or `gradlew` the same way. Pick the script from the OS. diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java index 9e6a3e9f5..d23f2b160 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java @@ -60,6 +60,12 @@ void dependencyPrintsCanonicalAgentGuide() { assertTrue(guide.contains("**Discover**")); assertTrue(guide.contains("**Confirm**")); assertTrue(guide.contains("Do not start the GUI")); + assertTrue(guide.contains("open-scenario")); + assertTrue(guide.contains("mvnw")); + assertTrue(guide.contains("mvnw.cmd")); + assertTrue(guide.contains("machine-wide")); + assertTrue(guide.contains("already open")); + assertFalse(guide.toLowerCase(java.util.Locale.ROOT).contains("only controls")); assertTrue(guide.contains("execute-step")); assertTrue(guide.contains("run-catalog.json")); assertTrue(guide.contains("PickleballWorkbenchLauncher")); diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 69c6e0f3f..2fc96d05e 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -28,6 +28,8 @@ The Workbench controller owns synchronization, worker process/session lifecycle, `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. +`WorkbenchSessionActions` is the one implementation of the actions that change the open session (`open-scenario`, `example`, `play`, `from-here`, `pause`, `execute-step`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `stop`). The Swing window calls those commands. The command queue calls them too. Do not add a second GUI-only copy. `example` is the same Examples-row filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every match. Advanced inspector buttons keep calling the existing `WorkbenchServices` methods that MCP already exposes. Do not add a pixel, WebView, or JavaFX click tool. + The Workbench bridge client uses only `tools.dscode.control.protocol.*`. Worker-side `ControlBridgeRuntime`, `ControlBridgeCoordinator`, bootstrap, adapters, step compilation, mappings, and execution semantics stay in Pickleball. Workbench may hold the worker entry-point class name as `ControlProtocol.WORKER_MAIN_CLASS`; it must never import or load that class. The canonical consumer-worker bridge environment is: @@ -107,7 +109,7 @@ Workbench provides: java -cp pickleball-workbench-.jar: tools.dscode.workbench.WorkbenchApplication mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the Java/Maven front door and launcher `isolate` / `execute-step` against the headless CLI session. Hosts may already wire `mcp .` as optional alias. Agents must not start the GUI. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the front door. When a Workbench window is already open, drive that session with the same commands the window calls so the person sees the result. When nobody is watching, launcher `isolate` / `execute-step` stay on the headless CLI session. Hosts may already wire `mcp .` as optional alias. Do not start the GUI and do not click the JavaFX or WebView UI. Do not embed Maven or Gradle in MCP or the launcher. If a tool needs a build, it invokes the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not add a general `mvn` or `gradle` tool. 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. @@ -119,7 +121,7 @@ Do not add generic IDE/file/build/process/collaboration tools to this MCP surfac ## Synchronization and worker lifecycle -Workbench synchronization is build-tool-assisted, not a replacement build system. Use the selected Maven/Gradle wrapper to establish compiled main/test output, processed resources, and the effective test runtime classpath. Gradle synchronization must use build-native init-script/task injection rather than the Gradle Tooling API. Compare **input** fingerprints (Java sources, resources, build files, dependency artifact bytes) to the last manifest before invoking the wrapper: skip when nothing that requires recompilation changed; run resource processing only when only feature/config/data changed; run full `test-compile` / `testClasses` when Java, the build descriptor, or dependencies changed. The output fingerprint in `manifest.json` is provenance, not the skip key. Always pass `-DskipTests`. Live Gherkin buffer edits must never require sync. If compiled project outputs are missing after a clean, escalate resources-only to full compile. +Workbench synchronization is build-tool-assisted, not a replacement build system. Use the selected project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar) to establish compiled main/test output, processed resources, and the effective test runtime classpath. Do not fall back to a machine-wide Maven or Gradle install. Gradle synchronization must use build-native init-script/task injection rather than the Gradle Tooling API. Compare **input** fingerprints (Java sources, resources, build files, dependency artifact bytes) to the last manifest before invoking the wrapper: skip when nothing that requires recompilation changed; run resource processing only when only feature/config/data changed; run full `test-compile` / `testClasses` when Java, the build descriptor, or dependencies changed. The output fingerprint in `manifest.json` is provenance, not the skip key. Always pass `-DskipTests`. Live Gherkin buffer edits must never require sync. If compiled project outputs are missing after a clean, escalate resources-only to full compile. `.pickleball/workbench/base/classes` is synchronization provenance/reset state and must never be on a worker runtime classpath. `.pickleball/workbench/live/classes` is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. Do not treat `live/classes` as an editor. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index 323228902..ab11c6786 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -6,28 +6,32 @@ A consumer project may contain only a short `AGENTS.md` bridge. That bridge uses ## Tool chooser -Pickleball Workbench is the one front door. It is a Java/Maven program, not a GUI requirement. Do not start the GUI. +Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not start the GUI. Do not click the JavaFX or WebView UI. -From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use the same Maven exec wrapper as the project pointer; only change `-Dexec.args`: +Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. + +From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use that wrapper and the same exec invocation as the project pointer; only change `-Dexec.args`: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" ``` -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, and `stop` — change `exec.args` only. +Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. + +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. -1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. -2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name/example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. -3. **Live debug** — `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each Maven exec exits; the session stays up. +1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. +2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. +3. **Live debug** — When nobody is watching, `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. When a Workbench window is already open, do not start another session. Drive that session with the same commands so the person sees the result: `open-scenario`, `example`, `play`, `from-here`, `execute-step`, `pause`, `stop`, `diagnostic-run`, and the worker, save, and refresh commands. `example` is the same Examples-row filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every match. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each launcher exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. -`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. `ui` is a host/human command. Agents must not use `ui`. If `workbench_*` tools already exist they are the same session, not a setup step. +`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Agents must not start the GUI. If `workbench_*` tools already exist they are the same session, not a setup step. Do not copy consumer features into `.pickleball` as a sandbox. ### Live isolation loop -After Discover has found the failing scenario, `isolate` starts a headless Workbench session if one is not already healthy. Later Maven exec commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. +After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. 1. `-Dexec.args=isolate` (or `session-start`) — starts the session from the last Discover snapshot and prints `ACK SESSION ...`. 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. @@ -50,7 +54,7 @@ After Discover has found the failing scenario, `isolate` starts a headless Workb Keep first-read small. After a successful export: 1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags/name. For live debug use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. +2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. 3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. 4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. 5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. @@ -144,7 +148,7 @@ Never supply `pkb_run_profile` or `pkb_run_profile.` as input. They are ### Default AI test-launch rule -When you launch Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative input. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. +When you launch Pickleball tests and the intended execution settings are known, run the project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar) and use `pkb_runvars` as the authoritative input. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. For an agent's bounded confirmation (not the human runner defaults), include diagnostic evidence controls, the browser ladder (keep a remote `pkb_browser`; otherwise prefer `CHROME_HEADLESS`), and high parallelism when more than one scenario will run. Documented AI Discover/Confirm `pkb_runvars` keys: @@ -161,7 +165,7 @@ Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failu These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario'" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario'" ``` After any diagnostic run, read `pkb_run_profile` from the pack. That is the complete resolved RunVar list, including inherited execution-context paths and the integer parallel count. Do not treat omitted `pkb_runvars` keys as equal to project `pickleball.properties`. Confirm stays on `pkb_runvars`. Live isolate/worker launch replays a sealed snapshot as `-Dpkb_overriderunvars=` and an ordinary snapshot as `-Dpkb_runvars=`; neither silently re-resolves from project defaults, and neither supplies `pkb_run_profile` as input. @@ -288,10 +292,10 @@ Agent git suspects are a local procedure against the consumer repo (`git log -n ## Diagnostic utility commands -The agent-facing name is Workbench. From a Maven consumer where Pickleball is on the test classpath, use the same launcher as the project pointer and only change `-Dexec.args`: +The agent-facing name is Workbench. From a consumer project where Pickleball is on the test classpath, use the project wrapper (`mvnw` or `mvnw.cmd`) and the same launcher as the project pointer. Only change `-Dexec.args`. Do not require a machine-wide Maven or Gradle install. ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" "-Dexec.args=hint" "-Dexec.args=discover [--tags ] [--name ] [--example ]" "-Dexec.args=confirm [--tags ] [--name ] [--example ]" @@ -391,7 +395,7 @@ Pickleball's example Maven consumer includes an opt-in mixed pass/fail suite tag Run it explicitly: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover --tags=@agent-pointer-eval" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover --tags=@agent-pointer-eval" ``` or `-Dpkb_runvars="pkb_tags=@agent-pointer-eval, pkb_browser=CHROME_HEADLESS"`. Do not add this tag to consumer `AGENTS.md` or Copilot pointer files. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md index 2e5824248..c7571bd82 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md @@ -18,7 +18,7 @@ This directory supports repository-native AI coding agents. It is not a runtime Agent adapters should remain small and point back to the canonical contract rather than copying the full project description. -The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use Workbench `hint` / `discover` / `confirm`, then `isolate` / `execute-step` / `status` / `events` / `stop` for live debug. Do not start the GUI. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. +The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. The nested `/maven-consumer-project/.github/copilot-instructions.md` is identical to `AGENTS.md` so Copilot Chat sees the same Workbench pointer. 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 b9426c14a..bd1e27c30 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,9 +6,9 @@ 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; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -18,7 +18,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | @@ -43,12 +43,12 @@ The standalone Workbench shadow JAR is controller-only and thin: Workbench class The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, `PKB_CONTROL_BRIDGE_SESSION_ID`, `PKB_CONTROL_BRIDGE_TOKEN`, and `PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO`. Pickleball may also accept the prior `PKB_STUDIO_BRIDGE_*` names as deprecated compatibility input aliases. New Workbench code must use only the neutral names. -`WorkbenchSynchronizer` uses the selected Maven/Gradle wrapper to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath; Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. Input fingerprints of Java sources, resources, build files, and dependency artifacts decide skip vs resources-only vs full compile; the output fingerprint in `manifest.json` remains provenance, not a skip key. Sync always passes `-DskipTests`. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. Live Gherkin buffer edits do not require sync. +`WorkbenchSynchronizer` uses the selected project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar) to run the minimum test-compilation/resource lifecycle and capture the effective test runtime dependency classpath. It does not fall back to a machine-wide Maven or Gradle install. Discover and confirm use the same Maven wrapper rule. MCP and the launcher do not embed Maven or Gradle and do not expose a general `mvn` or `gradle` tool. Gradle metadata is obtained with a temporary init script rather than the Gradle Tooling API. Input fingerprints of Java sources, resources, build files, and dependency artifacts decide skip vs resources-only vs full compile; the output fingerprint in `manifest.json` remains provenance, not a skip key. Sync always passes `-DskipTests`. It materializes `.pickleball/workbench/base/classes` as immutable synchronization provenance and one merged `.pickleball/workbench/live/classes` runtime root, applying main output first and test output second so test-owned paths win deterministically. `base` is never placed on `classpath.txt` or a worker classpath. The synchronization fingerprint includes dependency artifact contents as well as merged project output. Live Gherkin buffer edits do not require sync. `WorkbenchWorkerManager` launches a consumer JVM directly from that live root plus captured dependencies through the protocol-owned worker class-name contract, without linking the worker class or invoking Maven/Gradle. Interactive workers use a session-private anchor feature and a one-shot `BEFORE_STEP` breakpoint to reach an initialized, paused marker before the controller returns a live worker. Pause leases remain finite and are renewed while the controller owns the anchor. `WorkbenchLiveSession` binds operations to the controller-owned paused scenario and verifies worker PID, bridge runtime id, and scenario id stability. A `FAILED` `executeStep` result leaves that same paused worker available; it is not a worker-ending error. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door and launcher `isolate` / `execute-step` / `status` / `events` / `stop` for live debug against a detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` so a watcher can join a human GUI session instead of starting a second Workbench; that attach file is not the consumer-agent path. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. The agent does not start the GUI and does not click JavaFX or WebView. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not start the GUI to obtain it. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. 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 bbf44cbe5..82d488d93 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 @@ -405,6 +405,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` +- `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballArtifactLocatorTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java` @@ -504,6 +505,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValue.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlBridgeValueResult.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ControlProtocol.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/ExampleRowSelector.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballArtifactLocator.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java` @@ -511,6 +513,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballVersion.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlBridgeStepResolutionTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlProtocolStepSeedTest.java` +- `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java` ## Pickleball Workbench module @@ -579,6 +582,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchController.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchRuntimeBoundary.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchServices.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchLiveSession.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerManager.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/worker/WorkbenchWorkerStatus.java` @@ -623,6 +627,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `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/WorkbenchSessionActionsTest.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/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index 3bb897e95..747683a68 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -305,7 +305,7 @@ Example compact rerun: -Dpkb_changed_variables=pkb_browser ``` -For an agent's bounded confirmation `mvn test` (not `PickleballTests` human defaults of `pretty` / `@all`), include diagnostic evidence controls, headless Chrome, and high parallelism when more than one scenario will run: +For an agent's bounded confirmation, run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. This is not the `PickleballTests` human defaults of `pretty` / `@all`. Include diagnostic evidence controls, headless Chrome, and high parallelism when more than one scenario will run: ```text -Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_parallel=auto, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" @@ -430,7 +430,7 @@ When operating in a consumer project: ## AI agents -The agent-facing entry is Pickleball Workbench (`hint`, `discover`, `confirm` to find failures; `isolate` / `execute-step` for live debug). Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not start the GUI. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. ```text pkb_browser= @@ -440,7 +440,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -plus the narrowest useful `pkb_tags` / `pkb_name`. An agent can also pass `pkb_example`, or `confirm` / `isolate --example=...`, to run one or more Examples rows without the GUI. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. Live isolate stays one paused scenario (`pkb_parallel=1`) on a headless CLI session started with Maven-exec `isolate`. +plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not start the GUI. After Discover, inspect `pkb_run_profile` from `run-catalog.json`, `run-index.json`, or `summary.json`. Confirm and live isolate replay that retained profile through `pkb_runvars` (LastDiscoverSnapshot) unless the snapshot is marked sealed, in which case the next worker launch uses `-Dpkb_overriderunvars=`. If there is no prior Discover snapshot, Workbench says so; it does not silently re-resolve from project defaults. The Workbench sealed-RunVars panel, if unused, changes no behavior; Apply writes a sealed snapshot for the **next** launch and does not mutate an in-flight worker. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index 323228902..ab11c6786 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -6,28 +6,32 @@ A consumer project may contain only a short `AGENTS.md` bridge. That bridge uses ## Tool chooser -Pickleball Workbench is the one front door. It is a Java/Maven program, not a GUI requirement. Do not start the GUI. +Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not start the GUI. Do not click the JavaFX or WebView UI. -From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use the same Maven exec wrapper as the project pointer; only change `-Dexec.args`: +Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. + +From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use that wrapper and the same exec invocation as the project pointer; only change `-Dexec.args`: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" ``` -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, and `stop` — change `exec.args` only. +Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. + +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. -1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It wraps consumer `mvn test`. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. -2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags/name/example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. -3. **Live debug** — `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each Maven exec exits; the session stays up. +1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. +2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. +3. **Live debug** — When nobody is watching, `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. When a Workbench window is already open, do not start another session. Drive that session with the same commands so the person sees the result: `open-scenario`, `example`, `play`, `from-here`, `execute-step`, `pause`, `stop`, `diagnostic-run`, and the worker, save, and refresh commands. `example` is the same Examples-row filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every match. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each launcher exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. -`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. `ui` is a host/human command. Agents must not use `ui`. If `workbench_*` tools already exist they are the same session, not a setup step. +`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Agents must not start the GUI. If `workbench_*` tools already exist they are the same session, not a setup step. Do not copy consumer features into `.pickleball` as a sandbox. ### Live isolation loop -After Discover has found the failing scenario, `isolate` starts a headless Workbench session if one is not already healthy. Later Maven exec commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. +After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. 1. `-Dexec.args=isolate` (or `session-start`) — starts the session from the last Discover snapshot and prints `ACK SESSION ...`. 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. @@ -50,7 +54,7 @@ After Discover has found the failing scenario, `isolate` starts a headless Workb Keep first-read small. After a successful export: 1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags/name. For live debug use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. +2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. 3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. 4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. 5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. @@ -144,7 +148,7 @@ Never supply `pkb_run_profile` or `pkb_run_profile.` as input. They are ### Default AI test-launch rule -When you launch Pickleball tests and the intended execution settings are known, use `pkb_runvars` as the authoritative input. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. +When you launch Pickleball tests and the intended execution settings are known, run the project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar) and use `pkb_runvars` as the authoritative input. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Put intentional tag/name selection, browser, evidence/logging controls, and other non-secret RunVar changes inside `pkb_runvars`; do not default to ambient optional project settings or separate JVM `-Dpkb_*` RunVars. Use `pkb_profile` or ordinary JVM RunVar overrides only when the task specifically tests those configuration semantics or the user asks for them. Keep protected secrets and diagnostic lineage outside `pkb_runvars`. For an agent's bounded confirmation (not the human runner defaults), include diagnostic evidence controls, the browser ladder (keep a remote `pkb_browser`; otherwise prefer `CHROME_HEADLESS`), and high parallelism when more than one scenario will run. Documented AI Discover/Confirm `pkb_runvars` keys: @@ -161,7 +165,7 @@ Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failu These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario'" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario'" ``` After any diagnostic run, read `pkb_run_profile` from the pack. That is the complete resolved RunVar list, including inherited execution-context paths and the integer parallel count. Do not treat omitted `pkb_runvars` keys as equal to project `pickleball.properties`. Confirm stays on `pkb_runvars`. Live isolate/worker launch replays a sealed snapshot as `-Dpkb_overriderunvars=` and an ordinary snapshot as `-Dpkb_runvars=`; neither silently re-resolves from project defaults, and neither supplies `pkb_run_profile` as input. @@ -288,10 +292,10 @@ Agent git suspects are a local procedure against the consumer repo (`git log -n ## Diagnostic utility commands -The agent-facing name is Workbench. From a Maven consumer where Pickleball is on the test classpath, use the same launcher as the project pointer and only change `-Dexec.args`: +The agent-facing name is Workbench. From a consumer project where Pickleball is on the test classpath, use the project wrapper (`mvnw` or `mvnw.cmd`) and the same launcher as the project pointer. Only change `-Dexec.args`. Do not require a machine-wide Maven or Gradle install. ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" "-Dexec.args=hint" "-Dexec.args=discover [--tags ] [--name ] [--example ]" "-Dexec.args=confirm [--tags ] [--name ] [--example ]" @@ -391,7 +395,7 @@ Pickleball's example Maven consumer includes an opt-in mixed pass/fail suite tag Run it explicitly: ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover --tags=@agent-pointer-eval" +mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover --tags=@agent-pointer-eval" ``` or `-Dpkb_runvars="pkb_tags=@agent-pointer-eval, pkb_browser=CHROME_HEADLESS"`. Do not add this tag to consumer `AGENTS.md` or Copilot pointer files. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index 2da793751..70183a777 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -9,9 +9,11 @@ The nested consumer intentionally keeps its own Markdown minimal. Detailed usage From a Maven consumer with Pickleball on the test classpath: ```powershell -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" ``` +On other systems the same arguments go to `./mvnw`. Do not require a machine-wide Maven install. + Then read or browse: ```text @@ -28,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures, then `isolate` / `execute-step` for live debug. Do not start the GUI. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot @@ -58,15 +60,9 @@ Its runner starts a loopback-only local test site so scenarios can exercise Sele ## Requirements and run - JDK 21 -- Maven 3.9 or newer +- the project's Maven wrapper: `mvnw`, `mvnw.cmd`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar`. Pick the script from the OS. Do not require a machine-wide Maven install - Chrome available to Selenium, or another configured browser -```bash -mvn test -``` - -or use the included wrappers: - ```bash ./mvnw test ``` @@ -92,7 +88,7 @@ The test-scoped Pickleball dependency already contains its controller-only Workb .\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" ``` -Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Agents for this release should not use the GUI, `ui .`, or `attach.json` as their path. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. +Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Agents do not start the GUI and do not click it. When a person already has the window open, the same launcher commands drive that session so the person sees the result. When nobody is watching, `discover`, `confirm`, and `isolate` stay headless. Do not start the GUI in order to read `attach.json`. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. Runner defaults include: @@ -150,9 +146,9 @@ Common suite tags include `@all`, `@regression`, `@smoke`, `@browser`, and `@dat Controller/protocol migration checks must remain focused: use `@control-bridge` and/or `@step-override-bridge`, set `pkb_parallel=80` when practical, and do not run `@all` for Workbench isolation work. ```bash -mvn test -Dpkb_tags="@forms and @state-assertions" -mvn test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" -mvn test "-Dpkb_example=1 2 5 3.4 7-11" +./mvnw test -Dpkb_tags="@forms and @state-assertions" +./mvnw test -Dpkb_tags="@workflow and @nested-steps and not @block-conditionals" +./mvnw test "-Dpkb_example=1 2 5 3.4 7-11" ``` `pkb_example` runs after `pkb_tags` and `pkb_name`. It keeps Examples rows. It is not a tag. A normal Scenario counts as row 1. The same selector is `--example` on Workbench `discover`, `confirm`, and `isolate`. @@ -160,19 +156,21 @@ mvn test "-Dpkb_example=1 2 5 3.4 7-11" Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a bounded confirmation should not reuse those defaults. Use Workbench `confirm`, or a separate `pkb_runvars` command that honors the browser ladder: ```bash -PickleballWorkbenchLauncher confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11' +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11'" ``` +Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. + After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. The consumer `pom.xml` also defines Maven profiles such as: ```bash -mvn test -Pall -mvn test -Psmoke -mvn test -Pforms -mvn test -Pworkflow -mvn test -Pcomponents +./mvnw test -Pall +./mvnw test -Psmoke +./mvnw test -Pforms +./mvnw test -Pworkflow +./mvnw test -Pcomponents ``` Feature files remain authoritative for exact scenario tags. @@ -184,7 +182,7 @@ Reusable profiles live in `profiles.yaml`; local property-level overrides for ma For deterministic automation or AI reruns, use `pkb_runvars` as direct input: ```bash -mvn test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" +./mvnw test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" ``` or expanded members: @@ -240,7 +238,7 @@ Do not recursively ingest an entire run. ### Diagnostic CLI ```text -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=hint" +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=hint" "-Dexec.args=discover" "-Dexec.args=confirm --tags=@the-failing-tag --example=1.1" DiagnosticCli compare-runs [output-json] diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md b/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md index cf6f66d5b..9f5f94355 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md @@ -7,7 +7,7 @@ A consumer normally needs the Pickleball test dependency and one runner extendin ## Requirements - Java 21 -- Maven 3.9 or newer, or an equivalent Gradle setup +- the project's wrapper: `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install - a Selenium-supported browser for browser scenarios ## Maven dependency @@ -80,7 +80,11 @@ Named profile definitions use the same shared/local idea: define shared profiles ## Run ```bash -mvn test +./mvnw test +``` + +```powershell +.\mvnw.cmd test ``` ## Launch the matching Workbench @@ -88,12 +92,16 @@ mvn test The Pickleball dependency carries its version-matched, controller-only Workbench as an opaque nested thin JAR. Launch it from the consumer test classpath, or `java -jar` the published Pickleball artifact; do not add or version a second Workbench dependency: ```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ "-Dexec.args=ui ." ``` +```powershell +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=ui ." +``` + ```bash java -jar ~/.m2/repository/tools/dscode/pickleball//pickleball-.jar ui . ``` @@ -103,7 +111,7 @@ After `export-guidance`, `.pickleball/open/pickleball-workbench.sh` (`.cmd` / `. Filter normally with RunVars such as: ```bash -mvn test "-Dpkb_tags=@forms and not @dialogs" +./mvnw test "-Dpkb_tags=@forms and not @dialogs" ``` ## Named profiles @@ -116,13 +124,13 @@ qa: ``` ```bash -mvn test -Dpkb_profile=qa +./mvnw test -Dpkb_profile=qa ``` Multiple profile names compose left-to-right: ```bash -mvn test -Dpkb_profile=qa,browser_firefox +./mvnw test -Dpkb_profile=qa,browser_firefox ``` A named profile automatically receives missing project execution-context RunVars (`pkb_glue`, `pkb_features`, `pkb_datapath`, `pkb_callpath`, `pkb_componentpath`, and `pkb_configpath`) when those values exist in normal project configuration. Optional RunVars do not implicitly inherit. @@ -132,7 +140,7 @@ A named profile automatically receives missing project execution-context RunVars For automation or an AI agent, use `pkb_runvars` as the direct input: ```bash -mvn test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" +./mvnw test "-Dpkb_runvars=pkb_tags=@smoke, pkb_browser=CHROME_HEADLESS" ``` Project wiring omitted from a controlled input inherits only the six execution-context RunVars listed above. This lets a controlled rerun specify what changes without copying repetitive glue/resource wiring. 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 e6db5aba4..67f16a6e1 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 @@ -33,7 +33,7 @@ Feature-file selection is secondary and collapsed behind **Filter by feature**. ## Live Scenario Editor -The center editor is one ordinary Gherkin text document over `LiveScenarioPlayer`. Tab at the start of a line, or while only leading colons/spaces sit before the caret, inserts one extra Pickleball leading `:`; Shift-Tab removes one leading `:` if present. Mid-line Tab inserts a space and does not jump to another Swing control. Typing the first letters of a Gherkin keyword after optional leading colons/whitespace offers completion (Feature, Rule, Background, Scenario, Scenario Outline, Examples, Given, When, Then, And, But, `*`, IF, ELSE, ELSE-IF). Tab or Enter accepts the selected keyword and inserts a trailing space. When the completion popup is open, Tab accepts it; otherwise Tab at the indent prefix inserts `:`. Play, Step, and From Here keep using `LiveScenarioPlayer`. **Play** executes a derived plan: Background steps plus the selected scenario, with one Examples row substituted when selected. Clicking a line instantly seeks the playhead. The top player bar shows the Pickleball version. +The center editor is one ordinary Gherkin text document over `LiveScenarioPlayer`. Tab at the start of a line, or while only leading colons/spaces sit before the caret, inserts one extra Pickleball leading `:`; Shift-Tab removes one leading `:` if present. Mid-line Tab inserts a space and does not jump to another Swing control. Typing the first letters of a Gherkin keyword after optional leading colons/whitespace offers completion (Feature, Rule, Background, Scenario, Scenario Outline, Examples, Given, When, Then, And, But, `*`, IF, ELSE, ELSE-IF). Tab or Enter accepts the selected keyword and inserts a trailing space. When the completion popup is open, Tab accepts it; otherwise Tab at the indent prefix inserts `:`. Play, Step, and From Here keep using `LiveScenarioPlayer`. **Play** executes a derived plan: Background steps plus the selected scenario, with one Examples row substituted when selected. That row is the same selector as the `example` command and `pkb_example`. The Play button calls `play` and shows the first source-order match. Headless `pkb_example` still runs every match. Clicking a line instantly seeks the playhead. The top player bar shows the Pickleball version. Workbench chose OpenJFX `WebView` + `JFXPanel` over JCEF so Mapping and Diagnostic explorer stay Workbench-only Maven dependencies resolved onto the forked controller classpath. JDK 21 does not ship a modern browser component. If JavaFX cannot start, those panels use their text fallbacks. The live editor is always the Gherkin text buffer. The JavaFX unnamed-module warning on Windows is expected and is not a hang; the player window is shown before WebView starts. 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 0131b7178..0d9b9354d 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 @@ -45,15 +45,21 @@ META-INF/pickleball/workbench/pickleball-workbench.jar Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version. -Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures, then `isolate` / `execute-step` / `status` / `events` / `stop` for live debug. Do not start the GUI. +Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures. When a Workbench window is already open, the same session commands (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the rest of the table below) drive that session so the person sees the result. When nobody is watching, `isolate` / `execute-step` / `status` / `events` / `stop` stay the headless path. Do not start the GUI. Do not click the JavaFX or WebView UI. + +Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Do not require a machine-wide Maven or Gradle install. Discover, confirm, and sync invoke that wrapper themselves. MCP and the launcher stay Pickleball actions. There is no general `mvn` or `gradle` tool. ```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ "-Dexec.args=export-guidance .pickleball" ``` +```powershell +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball" +``` + ```bash "-Dexec.args=hint" "-Dexec.args=discover --tags=@smoke" @@ -64,9 +70,36 @@ mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ "-Dexec.args=execute-step --text='Given stay'" ``` -Same launcher; only change `-Dexec.args`. `isolate` starts a detached headless session; later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. +Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. + +`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not start the GUI. + +## The window is a client + +Every action that changes the open session already exists as a launcher command or a command-queue command. The Swing window calls that command. It does not keep a second implementation. + +| Window action | Command | +| --- | --- | +| Open a scenario | `open-scenario` (`--feature`, `--name`, optional `--example`) | +| Choose Examples rows | `example` — the same filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every matching row. | +| Play | `play` | +| From here | `from-here` | +| Step | `execute-step` | +| Pause | `pause` | +| Stop playback | `stop` | +| Insert a step | `insert-step` | +| Update the selected step | `update-step` | +| Save | `save` | +| Switch the retained diagnostic run | `diagnostic-run` (`--run`) | +| Refresh status | `refresh` | +| Synchronize the project | `session-sync` (the one-shot forwarded command remains `sync`) | +| Start, restart, or stop the worker | `worker-start`, `worker-restart`, `worker-stop` | + +Advanced inspector actions (breakpoints, Step Overrides, browser page and screenshot, service call, Mapping) already call `WorkbenchServices`, the same methods as the existing MCP tools. Take control, Allow, and Deny stay the existing lease tools. Do not add a pixel, WebView, or JavaFX click tool. + +When a Workbench window is already open, an agent uses these same commands against that session so the person sees the result. The agent does not start the GUI and does not click the JavaFX or WebView UI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. -`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. Play still picks one visible row in the Workbench player. Headless selection is `pkb_example`. Do not start the GUI. +Build tools stay the project wrapper. Discover, confirm, and `session-sync` invoke `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. MCP and the launcher do not embed Maven or Gradle and do not expose a general `mvn` or `gradle` tool. `hint`, `discover`, and `confirm` accept `--tags` / `--name` / `--example` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This retention overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. @@ -75,17 +108,17 @@ Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` pri `mcp` and `ui` remain host/human commands. Hosts that already run Workbench MCP can launch it from the consumer test classpath: ```bash -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ +./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ -Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher \ -Dexec.classpathScope=test \ "-Dexec.args=mcp ." ``` ```powershell -mvn -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." +.\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Agents for this release should not use the GUI, `ui .`, or `.pickleball/workbench/attach.json` as their path; see `.pickleball/AGENT-GUIDE.md`. +Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Agents do not start the GUI and do not click it. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. @@ -326,9 +359,9 @@ The agent should update `currentAction` as it works. Playhead, Mapping, Terminal 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 +### Driving a session a person is watching -The Swing UI is a human player. Consumer AI agents for this release should not attach to a GUI. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. +The Swing window is a client of the session commands above. Consumer agents do not start that window and do not click the JavaFX or WebView UI. When a person already has it open, the agent runs those same launcher commands against that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. Those tools are the same session, not a second implementation and not a setup step. 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: @@ -359,7 +392,7 @@ A Copilot or other MCP-style client finds that file in the consumer project, the Headless `java -cp : tools.dscode.workbench.WorkbenchApplication mcp ` stays stdio JSON-RPC only. That is optional host wiring, not an agent setup step. The consumer-agent live path is launcher `isolate` then `execute-step`. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. -A human-watched UI session is optional and separate. From `maven-consumer-project`, a person may start `ui .` and then a watcher can join `.pickleball/workbench/attach.json`. Do not launch a second `mcp` process against the same live UI session, and do not treat that attach file as the default agent path. +A person may start `ui .` and watch the window. The agent then uses the session commands against that open session. Do not launch a second `mcp` process against the same live UI session, and do not start the GUI in order to obtain `.pickleball/workbench/attach.json`. ## MCP stdio @@ -369,7 +402,7 @@ Start the lightweight non-Spring MCP server for a consumer project. This is opti java -cp $workbenchCp tools.dscode.workbench.WorkbenchApplication mcp ".\maven-consumer-project" ``` -Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures and `isolate` / `execute-step` for live debug. Do not document or use the Swing GUI, `ui .`, or `.pickleball/workbench/attach.json` as the agent path. +Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."` through `mvnw` or `mvnw.cmd`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures. When a window is already open they drive it with the session commands. When nobody is watching they use `isolate` / `execute-step`. Do not start the GUI. Do not click the JavaFX or WebView UI. Do not require a machine-wide Maven or Gradle install. The server uses the official Java MCP SDK core and stdio transport with the Jackson 2 JSON adapter. MCP dependencies are Workbench-only and are resolved onto the forked controller classpath at launch; they are not shaded into the thin nested JAR. Workbench deliberately does not use Spring Boot, Spring Framework, Spring AI, WebMVC, or Tomcat. diff --git a/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java b/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java index 32b1fc44b..6752b0347 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java @@ -45,5 +45,43 @@ void consumerPointersAreShortIdenticalAndNameTheCliSession() throws Exception { assertFalse(lowered.contains("do not isolate")); assertFalse(lowered.contains("do not maven-exec isolate")); assertFalse(lowered.contains("must") && lowered.contains("chrome_headless")); + assertFalse(lowered.contains("only controls")); + assertTrue(lowered.contains("open-scenario")); + assertTrue(lowered.contains("mvnw")); + assertTrue(lowered.contains("machine-wide")); + } + + @Test + void agentGuidesDriveAnOpenWindowAndUseTheProjectWrapper() throws Exception { + List guides = List.of( + Path.of("docs/consumer-agent-guide.md"), + Path.of("docs/ai-run-configuration.md"), + Path.of("docs/pickleball-workbench.md"), + Path.of("docs/consumer-project.md"), + Path.of("AGENTS.md"), + Path.of("pickleball-workbench/AGENTS.md") + ); + for (Path guide : guides) { + String text = Files.readString(guide); + String lowered = text.toLowerCase(Locale.ROOT); + assertTrue(lowered.contains("open-scenario"), guide.toString()); + assertTrue(lowered.contains("do not start the gui"), guide.toString()); + assertTrue(lowered.contains("mvnw"), guide.toString()); + assertTrue(lowered.contains("machine-wide"), guide.toString()); + assertTrue(lowered.contains("already open"), guide.toString()); + assertFalse(lowered.contains("only controls"), guide.toString()); + } + String chooserSource = Files.readString(Path.of("docs/consumer-agent-guide.md")); + int chooserStart = chooserSource.indexOf("## Tool chooser"); + int liveStart = chooserSource.indexOf("### Live isolation loop"); + String chooser = chooserSource.substring(chooserStart, liveStart); + assertTrue(chooser.contains("-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher")); + assertTrue(chooser.contains("-Dexec.args=discover")); + assertTrue(chooser.contains("-Dexec.args=isolate")); + assertTrue(chooser.contains("execute-step")); + assertFalse(chooser.contains("PickleballWorkbenchLauncher discover")); + assertFalse(chooser.toLowerCase(Locale.ROOT).contains("do not isolate")); + assertFalse(chooser.contains("attach.json")); + assertFalse(chooser.contains("ui .")); } } From d0bfb3c183513dbde46bfc100865cf12d8bee3f8 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 18:30:12 +0000 Subject: [PATCH 05/23] Bottom line: each consumer run gets a private directory, a short log line, and an inbox note the reader deletes. Run data lives under .pickleball/runs/. The short log is append-only with no file lock and drops lines older than three days. Coordination ids stay out of pkb_run_profile so confirm does not reuse another run's directory or browser profile. --- .../coordination/AgentCoordination.java | 652 ++++++++++++++++++ .../common/driver/DriverConstruction.java | 37 +- .../diagnostic/DiagnosticReporter.java | 27 +- .../dscode/common/reporting/logging/Log.java | 10 +- .../dscode/coredefinitions/GeneralSteps.java | 13 + .../tools/dscode/testengine/PKB_props.java | 14 +- .../dscode/testengine/PickleballRunner.java | 13 + .../coordination/AgentCoordinationTest.java | 257 +++++++ 8 files changed, 1011 insertions(+), 12 deletions(-) create mode 100644 src/main/java/tools/dscode/common/coordination/AgentCoordination.java create mode 100644 src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java diff --git a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java new file mode 100644 index 000000000..fd8ebc494 --- /dev/null +++ b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java @@ -0,0 +1,652 @@ +package tools.dscode.common.coordination; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import tools.dscode.control.protocol.PickleballLocalLayout; +import tools.dscode.testengine.PKB_props; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * Shared bulletin board for consumer agents, with private data per run. + * + *

The short log and inbox live under the consumer {@code .pickleball} directory. + * Reports, diagnostic packs, scratch, the browser profile, and a headless + * Workbench session file for that run live under {@code .pickleball/runs/}. + * Appending the short log does not take a file lock. A dead agent must not block + * the others. Lines older than three days are dropped on the next append.

+ */ +public final class AgentCoordination { + public static final String LOG_FILE = "agent-log"; + public static final String INBOX_DIRECTORY = "inbox"; + public static final String RUNS_DIRECTORY = "runs"; + public static final String ANY_AGENT = "any"; + public static final String RECORD_FILE = "record.json"; + public static final Duration RETENTION = Duration.ofDays(3); + public static final int PURPOSE_LIMIT = 240; + + private static final ObjectMapper JSON = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + private static final DateTimeFormatter RUN_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmssSSS'Z'") + .withZone(ZoneOffset.UTC); + private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,119}"); + private static final String ATTACHMENT_TEMP_PROPERTY = "dscode.logging.attachmentTempRoot"; + + private static volatile Run current; + + private AgentCoordination() { + } + + public record Request( + String runId, + String agentId, + String group, + Integer sequence, + String who, + String why, + String purpose + ) { + public static Request of( + String runId, + String agentId, + String group, + Integer sequence, + String who, + String why, + String purpose + ) { + return new Request( + blankToNull(runId), + blankToNull(agentId), + blankToNull(group), + sequence, + blankToNull(who), + blankToNull(why), + blankToNull(purpose) + ); + } + } + + public record Run( + Path project, + String runId, + String agentId, + String group, + Integer sequence, + String who, + String why, + Path dataDirectory, + Path recordFile + ) { + public Path reportsDirectory() { + return dataDirectory.resolve("reports"); + } + + public Path diagnosticDirectory() { + return dataDirectory.resolve("diagnostic"); + } + + public Path scratchDirectory() { + return dataDirectory.resolve("scratch"); + } + + public Path browserProfileDirectory() { + return dataDirectory.resolve("browser-profile"); + } + + public Path sessionDirectory() { + return dataDirectory.resolve("session"); + } + + public Path sessionStateFile() { + return sessionDirectory().resolve("cli-session.json"); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record RunRecord( + String runId, + String agentId, + String group, + Integer sequence, + String who, + String why, + String learned, + String startedAt, + String stoppedAt, + String status, + String dataPath + ) { + } + + public record InboxNote(Path file, String from, String at, String message, String record) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record InboxFile(String from, String at, String message, String record) { + } + + public static Run current() { + return current; + } + + public static void clearCurrent() { + current = null; + } + + public static Path currentBrowserProfile() { + Run run = current; + return run == null ? null : run.browserProfileDirectory(); + } + + public static Path reportHtmlOrDefault() { + Run run = current; + if (run == null) return Path.of("reports", "cucumber-report.html"); + return run.reportsDirectory().resolve("cucumber-report.html"); + } + + public static Path logFile(Path project) { + return PickleballLocalLayout.root(project).resolve(LOG_FILE); + } + + public static Path runDirectory(Path project, String runId) { + return PickleballLocalLayout.root(project).resolve(RUNS_DIRECTORY).resolve(requireSafeId(runId, "run id")); + } + + public static Run begin(Path project, Request request) { + return begin(project, request, Instant.now(), null); + } + + public static Run begin(Path project, Request request, Instant when, Appendable out) { + Instant clock = when == null ? Instant.now() : when; + Request safe = request == null ? Request.of(null, null, null, null, null, null, null) : request; + String runId = safe.runId() == null ? newRunId(clock) : requireSafeId(safe.runId(), "run id"); + Path data = runDirectory(project, runId); + Path recordFile = data.resolve(RECORD_FILE); + try { + if (Files.isRegularFile(recordFile)) { + RunRecord existing = readRecord(recordFile); + ensureTree(data); + Run adopted = toRun(project, data, recordFile, existing); + activate(adopted); + print(out, "run-id=" + adopted.runId()); + print(out, "run-record=" + adopted.recordFile()); + print(out, "short-log=" + logFile(project)); + return adopted; + } + boolean generatedAgent = safe.agentId() == null; + String agentId = generatedAgent ? newAgentId() : requireSafeId(safe.agentId(), "agent id"); + if (ANY_AGENT.equals(agentId)) { + throw new IllegalArgumentException("agent id 'any' is reserved for the shared inbox."); + } + ensureTree(data); + String purpose = oneLine(firstNonBlank(safe.why(), safe.purpose(), "run")); + RunRecord record = new RunRecord( + runId, + agentId, + safe.group(), + safe.sequence(), + safe.who(), + safe.why(), + null, + clock.toString(), + null, + "RUNNING", + data.toString() + ); + writeRecord(recordFile, record); + appendLog(project, clock, agentId, runId, "start", purpose, recordFile); + Run opened = toRun(project, data, recordFile, record); + activate(opened); + if (generatedAgent) print(out, "agent-id=" + agentId); + print(out, "run-id=" + runId); + print(out, "run-record=" + recordFile); + print(out, "short-log=" + logFile(project)); + return opened; + } catch (IOException failure) { + throw new IllegalStateException("Could not open run " + runId + ": " + failure.getMessage(), failure); + } + } + + public static Run openConsumerRun(Path project, Appendable out) { + Request request = Request.of( + System.getProperty(PKB_props.PKB_RUN_ID), + System.getProperty(PKB_props.PKB_AGENT_ID), + System.getProperty(PKB_props.PKB_RUN_GROUP), + parseOptionalSequence(System.getProperty(PKB_props.PKB_RUN_SEQUENCE)), + System.getProperty(PKB_props.PKB_RUN_WHO), + System.getProperty(PKB_props.PKB_RUN_WHY), + "consumer-run" + ); + Run run = begin(project, request, Instant.now(), out); + System.setProperty(PKB_props.PKB_RUN_ID, run.runId()); + System.setProperty(PKB_props.PKB_AGENT_ID, run.agentId()); + return run; + } + + public static void finish(Path project, String runId, String learned, String status) { + finish(project, runId, learned, status, Instant.now(), null); + } + + public static void finish( + Path project, + String runId, + String learned, + String status, + Instant when, + Appendable out + ) { + String safeRun = requireSafeId(runId, "run id"); + Instant clock = when == null ? Instant.now() : when; + Path recordFile = runDirectory(project, safeRun).resolve(RECORD_FILE); + if (!Files.isRegularFile(recordFile)) { + throw new IllegalStateException("No run record for " + safeRun + " at " + recordFile); + } + try { + RunRecord existing = readRecord(recordFile); + boolean alreadyStopped = existing.stoppedAt() != null && !existing.stoppedAt().isBlank(); + String learnedValue = blankToNull(learned); + String keptLearned = learnedValue != null ? learnedValue : existing.learned(); + String statusValue = blankToNull(status); + if (!alreadyStopped) { + if (statusValue == null) statusValue = "STOPPED"; + RunRecord updated = new RunRecord( + existing.runId(), + existing.agentId(), + existing.group(), + existing.sequence(), + existing.who(), + existing.why(), + keptLearned, + existing.startedAt(), + clock.toString(), + statusValue, + existing.dataPath() + ); + writeRecord(recordFile, updated); + String purpose = oneLine(firstNonBlank(learnedValue, existing.why(), statusValue)); + appendLog(project, clock, existing.agentId(), existing.runId(), "stop", purpose, recordFile); + print(out, "run-id=" + existing.runId()); + print(out, "run-record=" + recordFile); + return; + } + if (learnedValue != null && !learnedValue.equals(existing.learned())) { + RunRecord updated = new RunRecord( + existing.runId(), + existing.agentId(), + existing.group(), + existing.sequence(), + existing.who(), + existing.why(), + learnedValue, + existing.startedAt(), + existing.stoppedAt(), + existing.status(), + existing.dataPath() + ); + writeRecord(recordFile, updated); + appendLog( + project, + clock, + existing.agentId(), + existing.runId(), + "note", + oneLine(learnedValue), + recordFile + ); + } + print(out, "run-id=" + existing.runId()); + print(out, "run-record=" + recordFile); + } catch (IOException failure) { + throw new IllegalStateException("Could not finish run " + safeRun + ": " + failure.getMessage(), failure); + } + } + + public static void finishCurrent(String status, String learned, Appendable out) { + Run run = current; + if (run == null) return; + Path record = run.recordFile(); + if (!Files.isRegularFile(record)) return; + finish(run.project(), run.runId(), learned, status, Instant.now(), out); + } + + public static void appendLog( + Path project, + Instant when, + String agentId, + String runId, + String event, + String purpose, + Path record + ) throws IOException { + Instant clock = when == null ? Instant.now() : when; + String line = String.join("\t", + clock.toString(), + oneField(agentId), + oneField(runId), + oneField(event), + oneLine(purpose), + record == null ? "-" : oneField(record.toString()) + ); + Path file = logFile(project); + Files.createDirectories(file.getParent()); + byte[] bytes = (line + "\n").getBytes(StandardCharsets.UTF_8); + try (FileChannel channel = FileChannel.open( + file, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND + )) { + channel.write(ByteBuffer.wrap(bytes)); + } + pruneIfStale(file, clock); + } + + public static List readLog(Path project) throws IOException { + Path file = logFile(project); + if (!Files.isRegularFile(file)) return List.of(); + List lines = new ArrayList<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + if (!line.isBlank()) lines.add(line); + } + return List.copyOf(lines); + } + + public static List recentLog(Path project, int limit) throws IOException { + List lines = readLog(project); + if (limit <= 0 || lines.size() <= limit) return lines; + return List.copyOf(lines.subList(lines.size() - limit, lines.size())); + } + + public static void note(Path project, String agentId, String runId, String purpose, Path record, Appendable out) { + String agent = agentId == null || agentId.isBlank() ? newAgentId() : requireSafeId(agentId, "agent id"); + String run = runId == null || runId.isBlank() ? "-" : requireSafeId(runId, "run id"); + Path recordPath = record; + if ("-".equals(run)) recordPath = null; + try { + appendLog(project, Instant.now(), agent, run, "note", purpose, recordPath); + } catch (IOException failure) { + throw new IllegalStateException("Could not append short-log note: " + failure.getMessage(), failure); + } + if (agentId == null || agentId.isBlank()) print(out, "agent-id=" + agent); + print(out, "short-log=" + logFile(project)); + } + + public static Path writeInbox(Path project, String toAgent, String fromAgent, String message, String recordPath) { + String target = toAgent == null || toAgent.isBlank() || ANY_AGENT.equalsIgnoreCase(toAgent.trim()) + ? ANY_AGENT + : requireSafeId(toAgent, "agent id"); + String from = fromAgent == null || fromAgent.isBlank() ? newAgentId() : requireSafeId(fromAgent, "agent id"); + String text = message == null ? "" : message.strip(); + if (text.isEmpty()) throw new IllegalArgumentException("An inbox note needs one message."); + if (text.contains("\n") || text.contains("\r")) { + throw new IllegalArgumentException("An inbox note is one line."); + } + Path dir = inboxDirectory(project, target); + String name = Instant.now().toEpochMilli() + "-" + UUID.randomUUID() + ".json"; + Path file = dir.resolve(name); + try { + Files.createDirectories(dir); + InboxFile body = new InboxFile(from, Instant.now().toString(), text, blankToNull(recordPath)); + writeJson(file, body); + return file; + } catch (IOException failure) { + throw new IllegalStateException("Could not write inbox note: " + failure.getMessage(), failure); + } + } + + public static List listInbox(Path project, String agentId) { + return List.copyOf(readInbox(project, agentId, false)); + } + + public static List takeInbox(Path project, String agentId) { + return List.copyOf(readInbox(project, agentId, true)); + } + + public static List mavenProperties(Run run) { + List properties = new ArrayList<>(); + properties.add("-D" + PKB_props.PKB_RUN_ID + "=" + run.runId()); + properties.add("-D" + PKB_props.PKB_AGENT_ID + "=" + run.agentId()); + if (run.group() != null) properties.add("-D" + PKB_props.PKB_RUN_GROUP + "=" + oneLine(run.group())); + if (run.sequence() != null) properties.add("-D" + PKB_props.PKB_RUN_SEQUENCE + "=" + run.sequence()); + if (run.who() != null) properties.add("-D" + PKB_props.PKB_RUN_WHO + "=" + oneLine(run.who())); + if (run.why() != null) properties.add("-D" + PKB_props.PKB_RUN_WHY + "=" + oneLine(run.why())); + return List.copyOf(properties); + } + + public static String requireSafeId(String id, String label) { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException(label + " is required."); + } + String value = id.trim(); + if (!SAFE_ID.matcher(value).matches() || value.contains("..")) { + throw new IllegalArgumentException( + label + " must be a single folder name (letters, digits, '.', '_' or '-'): " + id + ); + } + return value; + } + + public static String oneLine(String value) { + if (value == null || value.isBlank()) return "-"; + String collapsed = value.replace('\t', ' ').replace('\r', ' ').replace('\n', ' ').strip(); + if (collapsed.isEmpty()) return "-"; + if (collapsed.length() <= PURPOSE_LIMIT) return collapsed; + return collapsed.substring(0, PURPOSE_LIMIT); + } + + private static List readInbox(Path project, String agentId, boolean take) { + List files = new ArrayList<>(); + if (agentId != null && !agentId.isBlank() && !ANY_AGENT.equalsIgnoreCase(agentId.trim())) { + files.addAll(noteFiles(inboxDirectory(project, requireSafeId(agentId, "agent id")))); + } + files.addAll(noteFiles(inboxDirectory(project, ANY_AGENT))); + files.sort(Comparator.comparing(path -> path.getFileName().toString())); + List notes = new ArrayList<>(); + for (Path file : files) { + try { + byte[] bytes = Files.readAllBytes(file); + if (take && !Files.deleteIfExists(file)) continue; + InboxFile body = JSON.readValue(bytes, InboxFile.class); + notes.add(new InboxNote( + file, + body.from() == null ? "" : body.from(), + body.at() == null ? "" : body.at(), + body.message() == null ? "" : body.message(), + body.record() + )); + } catch (IOException ignored) { + // A note that disappeared or cannot be read is not history. + } + } + return notes; + } + + private static List noteFiles(Path directory) { + if (!Files.isDirectory(directory)) return List.of(); + try (var paths = Files.list(directory)) { + return paths.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json")) + .toList(); + } catch (IOException ignored) { + return List.of(); + } + } + + private static Path inboxDirectory(Path project, String agentFolder) { + return PickleballLocalLayout.root(project).resolve(INBOX_DIRECTORY).resolve(agentFolder); + } + + private static void ensureTree(Path data) throws IOException { + Files.createDirectories(data.resolve("reports")); + Files.createDirectories(data.resolve("diagnostic")); + Files.createDirectories(data.resolve("scratch")); + Files.createDirectories(data.resolve("browser-profile")); + Files.createDirectories(data.resolve("session")); + Files.writeString(data.resolve("run-id.txt"), data.getFileName().toString() + "\n", StandardCharsets.UTF_8); + } + + private static void activate(Run run) { + current = run; + System.setProperty(ATTACHMENT_TEMP_PROPERTY, run.scratchDirectory().toString()); + } + + private static Run toRun(Path project, Path data, Path recordFile, RunRecord record) { + return new Run( + project.toAbsolutePath().normalize(), + record.runId(), + record.agentId(), + record.group(), + record.sequence(), + record.who(), + record.why(), + data.toAbsolutePath().normalize(), + recordFile.toAbsolutePath().normalize() + ); + } + + private static void pruneIfStale(Path file, Instant now) throws IOException { + if (!Files.isRegularFile(file)) return; + byte[] snapshot = Files.readAllBytes(file); + if (!containsStale(snapshot, now)) return; + byte[] latest = Files.readAllBytes(file); + byte[] source = latest.length >= snapshot.length ? latest : snapshot; + String filtered = filterLines(source, now); + if (latest.length > source.length) { + filtered = filtered + filterLines(slice(latest, source.length), now); + } + Path temp = file.resolveSibling(file.getFileName() + ".prune-" + UUID.randomUUID()); + Files.writeString(temp, filtered, StandardCharsets.UTF_8); + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static boolean containsStale(byte[] bytes, Instant now) { + Instant cutoff = now.minus(RETENTION); + for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n", -1)) { + Instant stamp = timestampOf(line); + if (stamp != null && stamp.isBefore(cutoff)) return true; + } + return false; + } + + private static String filterLines(byte[] bytes, Instant now) { + Instant cutoff = now.minus(RETENTION); + StringBuilder kept = new StringBuilder(); + for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n", -1)) { + if (line.isBlank()) continue; + Instant stamp = timestampOf(line); + if (stamp != null && stamp.isBefore(cutoff)) continue; + kept.append(line).append('\n'); + } + return kept.toString(); + } + + private static byte[] slice(byte[] bytes, int from) { + byte[] extra = new byte[bytes.length - from]; + System.arraycopy(bytes, from, extra, 0, extra.length); + return extra; + } + + static Instant timestampOf(String line) { + if (line == null || line.isBlank()) return null; + int tab = line.indexOf('\t'); + String first = tab < 0 ? line.trim() : line.substring(0, tab).trim(); + try { + return Instant.parse(first); + } catch (RuntimeException ignored) { + return null; + } + } + + private static RunRecord readRecord(Path file) throws IOException { + return JSON.readValue(file.toFile(), RunRecord.class); + } + + private static void writeRecord(Path file, RunRecord record) throws IOException { + writeJson(file, record); + } + + private static void writeJson(Path file, Object value) throws IOException { + Files.createDirectories(file.getParent()); + Path temp = file.resolveSibling(file.getFileName() + ".tmp-" + UUID.randomUUID()); + JSON.writeValue(temp.toFile(), value); + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static String newRunId(Instant when) { + String stamp = RUN_STAMP.format(when); + return stamp + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + private static String newAgentId() { + return "agent-" + UUID.randomUUID().toString().substring(0, 8); + } + + private static Integer parseOptionalSequence(String value) { + if (value == null || value.isBlank()) return null; + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("pkb_run_sequence must be a whole number: " + value); + } + } + + private static String oneField(String value) { + String cleaned = oneLine(value); + return "-".equals(cleaned) ? "-" : cleaned; + } + + private static String firstNonBlank(String... values) { + if (values == null) return null; + for (String value : values) { + if (value != null && !value.isBlank() && !"-".equals(value.trim())) return value; + } + return null; + } + + private static String blankToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static void print(Appendable out, String line) { + if (out == null || line == null) return; + try { + out.append(line).append(System.lineSeparator()); + } catch (IOException ignored) { + // Printing the id must not hide the run itself. + } + } + + static String normalizeEvent(String event) { + if (event == null || event.isBlank()) return "note"; + return event.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/tools/dscode/common/driver/DriverConstruction.java b/src/main/java/tools/dscode/common/driver/DriverConstruction.java index c0ad2d435..803bb6971 100644 --- a/src/main/java/tools/dscode/common/driver/DriverConstruction.java +++ b/src/main/java/tools/dscode/common/driver/DriverConstruction.java @@ -17,9 +17,14 @@ import org.openqa.selenium.remote.http.ClientConfig; import org.openqa.selenium.remote.service.DriverService; +import tools.dscode.common.coordination.AgentCoordination; + +import java.io.IOException; import java.io.File; import java.io.OutputStream; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; @@ -192,8 +197,10 @@ public static ChromeOptions buildChromeOptions(ObjectNode driverConfig, ObjectNo applyChromiumOptions(options, getObject(driverConfig, "options"), "driver.options for chrome"); applyProviderCapabilities(options, getObject(driverConfig, "providerCapabilities")); - if (localDriver) + if (localDriver) { + applyRunBrowserProfile(options); applyDebugPort(options, "chrome", fullConfiguration); + } return options; } @@ -211,8 +218,10 @@ public static EdgeOptions buildEdgeOptions(ObjectNode driverConfig, ObjectNode f applyChromiumOptions(options, getObject(driverConfig, "options"), "driver.options for edge"); applyProviderCapabilities(options, getObject(driverConfig, "providerCapabilities")); - if (localDriver) + if (localDriver) { + applyRunBrowserProfile(options); applyDebugPort(options, "edge", fullConfiguration); + } return options; } @@ -487,6 +496,30 @@ public static boolean hasRemoteUrl(ObjectNode driverConfig) { return trimToNull(driverConfig.path("connection").path("remoteUrl").asText(null)) != null; } + public static void applyRunBrowserProfile(ChromiumOptions options) { + Path profile = AgentCoordination.currentBrowserProfile(); + if (profile == null || containsUserDataDir(options)) return; + try { + Files.createDirectories(profile); + } catch (IOException failure) { + return; + } + options.addArguments("--user-data-dir=" + profile.toAbsolutePath().normalize()); + } + + private static boolean containsUserDataDir(ChromiumOptions options) { + for (String capability : List.of("goog:chromeOptions", "ms:edgeOptions")) { + Object raw = options.getCapability(capability); + if (!(raw instanceof Map map)) continue; + Object args = map.get("args"); + if (!(args instanceof List list)) continue; + for (Object arg : list) { + if (arg != null && arg.toString().contains("user-data-dir")) return true; + } + } + return false; + } + public static void applyDebugPort(ChromiumOptions options, String browserName, ObjectNode fullConfiguration) { if (!getCurrentScenarioState().debugBrowser) { return; diff --git a/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticReporter.java b/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticReporter.java index 075e1ee59..3e1973a19 100644 --- a/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticReporter.java +++ b/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticReporter.java @@ -10,11 +10,13 @@ import org.openqa.selenium.OutputType; import org.openqa.selenium.TakesScreenshot; import org.openqa.selenium.WebDriver; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.reporting.logging.Entry; import tools.dscode.common.reporting.logging.Level; import tools.dscode.testengine.PKB_props; import static tools.dscode.testengine.PKB_props.PKB_DIAGNOSTIC_OUTPUT; +import static tools.dscode.testengine.PKB_props.PKB_RUN_ID; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -56,7 +58,7 @@ final class DiagnosticReporter { private static final int MAX_TEXT_CHARS = 20_000; private static final int MAX_COLLECTION_ITEMS = 200; - private final String runId = RUN_STAMP.format(Instant.now()) + "-" + UUID.randomUUID().toString().substring(0, 8); + private final String runId; private final Instant startedAt = Instant.now(); private final long monotonicOriginNanos = System.nanoTime(); private final Path runsRoot; @@ -91,7 +93,8 @@ final class DiagnosticReporter { this.effectiveConfig = values == null ? Map.of() : Map.copyOf(values); this.directRunProfile = directRunProfile; this.sourceProvenance = SourceProvenance.capture(values); - this.runsRoot = resolveRunsRoot(values); + this.runId = resolveRunId(this.effectiveConfig); + this.runsRoot = resolveRunsRoot(this.effectiveConfig); this.runRoot = runsRoot.resolve(runId); this.runEvents = runRoot.resolve("run-events.jsonl"); try { @@ -1074,11 +1077,25 @@ private void failEvidence(String action, Throwable error) { System.err.println("[Pickleball diagnostic] Could not " + action + ": " + error.getMessage()); } + private static String resolveRunId(Map values) { + String supplied = find(values, PKB_RUN_ID); + if (supplied != null && !supplied.isBlank()) { + return AgentCoordination.requireSafeId(supplied.trim(), "run id"); + } + return RUN_STAMP.format(Instant.now()) + "-" + UUID.randomUUID().toString().substring(0, 8); + } + private static Path resolveRunsRoot(Map values) { String configured = find(values, PKB_DIAGNOSTIC_OUTPUT); - return configured == null || configured.isBlank() - ? Path.of("reports", "diagnostic-runs") - : Path.of(configured.trim()); + if (configured != null && !configured.isBlank()) { + return Path.of(configured.trim()); + } + String runId = find(values, PKB_RUN_ID); + AgentCoordination.Run current = AgentCoordination.current(); + if (current != null && runId != null && runId.trim().equals(current.runId())) { + return current.diagnosticDirectory(); + } + return Path.of("reports", "diagnostic-runs"); } private static String find(Map values, String key) { diff --git a/src/main/java/tools/dscode/common/reporting/logging/Log.java b/src/main/java/tools/dscode/common/reporting/logging/Log.java index 0ba7152d5..63c825107 100644 --- a/src/main/java/tools/dscode/common/reporting/logging/Log.java +++ b/src/main/java/tools/dscode/common/reporting/logging/Log.java @@ -1,6 +1,7 @@ // file: tools/dscode/common/reporting/logging/Log.java package tools.dscode.common.reporting.logging; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.reporting.WorkBook; import tools.dscode.common.reporting.logging.reportportal.ReportPortalBridge; import tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter; @@ -89,13 +90,14 @@ public void closeAll() { throw t; } - CleanupTrace.print("[closeAll] START: SimpleHtmlReportConverter.writeFinalReport(reports/cucumber-report.html)"); + Path reportHtml = AgentCoordination.reportHtmlOrDefault(); + CleanupTrace.print("[closeAll] START: SimpleHtmlReportConverter.writeFinalReport(" + reportHtml + ")"); try { - SimpleHtmlReportConverter.writeFinalReport(Path.of("reports/cucumber-report.html")); - CleanupTrace.print("[closeAll] END: SimpleHtmlReportConverter.writeFinalReport(reports/cucumber-report.html)"); + SimpleHtmlReportConverter.writeFinalReport(reportHtml); + CleanupTrace.print("[closeAll] END: SimpleHtmlReportConverter.writeFinalReport(" + reportHtml + ")"); } catch (Throwable t) { CleanupTrace.printThrowable( - "[closeAll] THROWABLE: SimpleHtmlReportConverter.writeFinalReport(reports/cucumber-report.html)", + "[closeAll] THROWABLE: SimpleHtmlReportConverter.writeFinalReport(" + reportHtml + ")", t ); throw t; diff --git a/src/main/java/tools/dscode/coredefinitions/GeneralSteps.java b/src/main/java/tools/dscode/coredefinitions/GeneralSteps.java index 53dd5367d..be27989f2 100644 --- a/src/main/java/tools/dscode/coredefinitions/GeneralSteps.java +++ b/src/main/java/tools/dscode/coredefinitions/GeneralSteps.java @@ -7,6 +7,7 @@ import io.cucumber.java.BeforeAll; import io.cucumber.java.en.Given; //import io.cucumber.messages.types.DataTable; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.CoreSteps; import tools.dscode.common.annotations.DefinitionFlag; import tools.dscode.common.annotations.DefinitionFlags; @@ -199,6 +200,18 @@ public static void afterAll() { if (failure != null) { CleanupTrace.print("[AfterAll] THROWING accumulated failure"); + } + + CleanupTrace.print("[AfterAll] START: AgentCoordination.finishCurrent"); + try { + AgentCoordination.finishCurrent(failure == null ? "PASSED" : "FAILED", null, System.out); + CleanupTrace.print("[AfterAll] END: AgentCoordination.finishCurrent"); + } catch (Throwable t) { + CleanupTrace.printThrowable("[AfterAll] THROWABLE: AgentCoordination.finishCurrent", t); + failure = rememberFailure(failure, t); + } + + if (failure != null) { throw new RuntimeException(failure); } diff --git a/src/main/java/tools/dscode/testengine/PKB_props.java b/src/main/java/tools/dscode/testengine/PKB_props.java index b8e9f6a88..e1e690c4d 100644 --- a/src/main/java/tools/dscode/testengine/PKB_props.java +++ b/src/main/java/tools/dscode/testengine/PKB_props.java @@ -54,6 +54,12 @@ public final class PKB_props { public static final String PKB_PARENT_RUN_ID = PKB_PREFIX + "parent_run_id"; public static final String PKB_BASELINE_RUN_ID = PKB_PREFIX + "baseline_run_id"; public static final String PKB_CHANGED_VARIABLES = PKB_PREFIX + "changed_variables"; + public static final String PKB_RUN_ID = PKB_PREFIX + "run_id"; + public static final String PKB_AGENT_ID = PKB_PREFIX + "agent_id"; + public static final String PKB_RUN_GROUP = PKB_PREFIX + "run_group"; + public static final String PKB_RUN_SEQUENCE = PKB_PREFIX + "run_sequence"; + public static final String PKB_RUN_WHO = PKB_PREFIX + "run_who"; + public static final String PKB_RUN_WHY = PKB_PREFIX + "run_why"; public static final String PKB_DEBUG_BROWSER = PKB_PREFIX + "debugBrowser"; public static final String PKB_DEBUG_ARGS = PKB_PREFIX + "debugargs"; @@ -63,7 +69,13 @@ public final class PKB_props { PKB_RUN_PURPOSE, PKB_PARENT_RUN_ID, PKB_BASELINE_RUN_ID, - PKB_CHANGED_VARIABLES + PKB_CHANGED_VARIABLES, + PKB_RUN_ID, + PKB_AGENT_ID, + PKB_RUN_GROUP, + PKB_RUN_SEQUENCE, + PKB_RUN_WHO, + PKB_RUN_WHY ); private PKB_props() { diff --git a/src/main/java/tools/dscode/testengine/PickleballRunner.java b/src/main/java/tools/dscode/testengine/PickleballRunner.java index 276d3957d..4b3cf1307 100644 --- a/src/main/java/tools/dscode/testengine/PickleballRunner.java +++ b/src/main/java/tools/dscode/testengine/PickleballRunner.java @@ -2,6 +2,7 @@ import com.epam.reportportal.utils.properties.PropertiesLoader; import io.cucumber.core.runner.CurrentScenarioState; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.mappings.ParsingMap; import tools.dscode.common.reporting.logging.Level; import tools.dscode.control.protocol.PickleballLocalLayout; @@ -127,6 +128,18 @@ protected PickleballRunner() { ? "INFO" : configuredLogLevel.trim(); LOG_LEVEL = Level.valueOf(effectiveLogLevel.toUpperCase(Locale.ROOT)); + AgentCoordination.Run coordination = AgentCoordination.openConsumerRun( + PickleballLocalLayout.findProjectRoot(java.nio.file.Path.of("")), + System.out + ); + values.put(PKB_props.PKB_RUN_ID, coordination.runId()); + values.put(PKB_props.PKB_AGENT_ID, coordination.agentId()); + if (coordination.group() != null) values.put(PKB_props.PKB_RUN_GROUP, coordination.group()); + if (coordination.sequence() != null) { + values.put(PKB_props.PKB_RUN_SEQUENCE, coordination.sequence().toString()); + } + if (coordination.who() != null) values.put(PKB_props.PKB_RUN_WHO, coordination.who()); + if (coordination.why() != null) values.put(PKB_props.PKB_RUN_WHY, coordination.why()); } public static String getOptionsString() { diff --git a/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java new file mode 100644 index 000000000..f6e7407a9 --- /dev/null +++ b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java @@ -0,0 +1,257 @@ +package tools.dscode.common.coordination; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openqa.selenium.chrome.ChromeOptions; +import tools.dscode.common.driver.DriverConstruction; +import tools.dscode.testengine.PKB_props; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AgentCoordinationTest { + @TempDir + Path project; + + @AfterEach + void clearCoordination() { + AgentCoordination.clearCurrent(); + } + + @Test + void twoRunsGetPrivateDirectoriesAndDoNotWriteEachOthersFiles() throws Exception { + AgentCoordination.Run first = AgentCoordination.begin(project, request("run-a", "agent-a")); + AgentCoordination.Run second = AgentCoordination.begin(project, request("run-b", "agent-b")); + assertNotEquals(first.dataDirectory(), second.dataDirectory()); + assertNotEquals(first.browserProfileDirectory(), second.browserProfileDirectory()); + assertNotEquals(first.sessionStateFile(), second.sessionStateFile()); + + Files.writeString(first.reportsDirectory().resolve("out.txt"), "alpha"); + Files.writeString(first.scratchDirectory().resolve("note.txt"), "scratch-a"); + Files.writeString(first.browserProfileDirectory().resolve("profile.txt"), "profile-a"); + Files.writeString(second.reportsDirectory().resolve("out.txt"), "beta"); + Files.writeString(second.sessionStateFile(), "session-b"); + + assertEquals("alpha", Files.readString(first.reportsDirectory().resolve("out.txt"))); + assertEquals("beta", Files.readString(second.reportsDirectory().resolve("out.txt"))); + assertFalse(Files.exists(second.scratchDirectory().resolve("note.txt"))); + assertFalse(Files.exists(first.sessionStateFile())); + assertEquals("session-b", Files.readString(second.sessionStateFile())); + assertEquals("profile-a", Files.readString(first.browserProfileDirectory().resolve("profile.txt"))); + assertTrue(Files.isRegularFile(first.recordFile())); + assertTrue(Files.isRegularFile(second.recordFile())); + } + + @Test + void omittedRunIdIsGeneratedAndAPassedInIdIsKept() { + StringBuilder printed = new StringBuilder(); + AgentCoordination.Run generated = AgentCoordination.begin( + project, + AgentCoordination.Request.of(null, null, null, null, null, null, "discover"), + Instant.parse("2026-10-01T15:04:05Z"), + printed + ); + assertFalse(generated.runId().isBlank()); + assertTrue(generated.runId().startsWith("20261001-150405000Z-")); + assertTrue(generated.agentId().startsWith("agent-")); + assertTrue(printed.toString().contains("agent-id=" + generated.agentId())); + assertTrue(printed.toString().contains("run-id=" + generated.runId())); + + AgentCoordination.Run kept = AgentCoordination.begin( + project, + AgentCoordination.Request.of("kept-run-9", "agent-kept", "wave", 3, "Dan", "check the row", "confirm"), + Instant.parse("2026-10-01T15:05:00Z"), + new StringBuilder() + ); + assertEquals("kept-run-9", kept.runId()); + assertEquals("agent-kept", kept.agentId()); + assertEquals("wave", kept.group()); + assertEquals(3, kept.sequence()); + assertEquals("Dan", kept.who()); + assertEquals("check the row", kept.why()); + assertTrue(PKB_props.isRunMetadataKey("pkb_run_id")); + assertTrue(PKB_props.isRunMetadataKey("pkb_agent_id")); + assertFalse(PKB_props.isRunVariableKey("pkb_run_id")); + } + + @Test + void shortLogAppendsStartAndStopWithTheRunPath() throws Exception { + AgentCoordination.Run run = AgentCoordination.begin(project, request("run-log", "agent-log")); + AgentCoordination.finish(project, run.runId(), "the empty row fails", "PASSED"); + List lines = AgentCoordination.readLog(project); + assertEquals(2, lines.size()); + assertTrue(lines.get(0).contains("\tstart\t")); + assertTrue(lines.get(1).contains("\tstop\t")); + assertTrue(lines.get(0).contains(run.recordFile().toString())); + assertTrue(lines.get(1).contains(run.recordFile().toString())); + assertTrue(lines.get(1).contains("the empty row fails")); + String record = Files.readString(run.recordFile()); + assertTrue(record.contains("\"learned\" : \"the empty row fails\"") || record.contains("\"learned\":\"the empty row fails\"")); + assertTrue(record.contains("PASSED")); + assertFalse(record.contains("\"stoppedAt\" : null") && record.contains("\"status\" : \"RUNNING\"")); + } + + @Test + void linesOlderThanThreeDaysAreRemovedAndNewerLinesStay() throws Exception { + Instant now = Instant.parse("2026-10-01T12:00:00Z"); + Instant exactly = now.minus(Duration.ofDays(3)); + Instant older = exactly.minusMillis(1); + AgentCoordination.appendLog(project, older, "agent-a", "old-run", "note", "gone", null); + AgentCoordination.appendLog(project, exactly, "agent-a", "edge-run", "note", "stay-edge", null); + AgentCoordination.appendLog(project, now.minus(Duration.ofHours(1)), "agent-a", "recent-run", "note", "stay-recent", null); + AgentCoordination.appendLog(project, now, "agent-a", "fresh-run", "note", "stay-now", null); + + String log = Files.readString(AgentCoordination.logFile(project), StandardCharsets.UTF_8); + assertFalse(log.contains("old-run")); + assertFalse(log.contains("gone")); + assertTrue(log.contains("edge-run")); + assertTrue(log.contains("stay-edge")); + assertTrue(log.contains("recent-run")); + assertTrue(log.contains("fresh-run")); + } + + @Test + void twoAppendsDoNotNeedALockAndDoNotCorruptLines() throws Exception { + int perThread = 40; + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch go = new CountDownLatch(1); + try { + Future left = pool.submit(() -> appendMany("agent-left", perThread, ready, go)); + Future right = pool.submit(() -> appendMany("agent-right", perThread, ready, go)); + ready.await(); + go.countDown(); + left.get(); + right.get(); + } finally { + pool.shutdownNow(); + } + List lines = AgentCoordination.readLog(project); + assertEquals(perThread * 2, lines.size()); + int leftCount = 0; + int rightCount = 0; + for (String line : lines) { + String[] fields = line.split("\t", -1); + assertEquals(6, fields.length, line); + assertFalse(line.contains("\n")); + if ("agent-left".equals(fields[1])) leftCount++; + if ("agent-right".equals(fields[1])) rightCount++; + } + assertEquals(perThread, leftCount); + assertEquals(perThread, rightCount); + } + + @Test + void inboxIsAddressedAndTakingDeletesTheNote() throws Exception { + AgentCoordination.writeInbox(project, "agent-a", "agent-writer", "for a only", null); + AgentCoordination.writeInbox(project, "agent-b", "agent-writer", "for b only", null); + Path any = AgentCoordination.writeInbox(project, "any", "agent-writer", "for anyone", null); + + List forA = AgentCoordination.listInbox(project, "agent-a"); + List forB = AgentCoordination.listInbox(project, "agent-b"); + List forC = AgentCoordination.listInbox(project, "agent-c"); + assertTrue(forA.stream().anyMatch(note -> note.message().equals("for a only"))); + assertFalse(forA.stream().anyMatch(note -> note.message().equals("for b only"))); + assertTrue(forA.stream().anyMatch(note -> note.message().equals("for anyone"))); + assertTrue(forB.stream().anyMatch(note -> note.message().equals("for b only"))); + assertFalse(forB.stream().anyMatch(note -> note.message().equals("for a only"))); + assertEquals(1, forC.size()); + assertEquals("for anyone", forC.getFirst().message()); + + List taken = AgentCoordination.takeInbox(project, "agent-a"); + assertEquals(2, taken.size()); + assertFalse(Files.exists(any)); + assertTrue(AgentCoordination.listInbox(project, "agent-a").isEmpty()); + assertEquals(1, AgentCoordination.listInbox(project, "agent-b").size()); + assertEquals("for b only", AgentCoordination.listInbox(project, "agent-b").getFirst().message()); + AgentCoordination.takeInbox(project, "agent-b"); + assertTrue(AgentCoordination.listInbox(project, "agent-b").isEmpty()); + assertTrue(AgentCoordination.listInbox(project, "agent-c").isEmpty()); + } + + @Test + void adoptingAnExistingRunDoesNotWriteASecondStartLine() throws Exception { + AgentCoordination.begin(project, request("run-adopt", "agent-adopt")); + AgentCoordination.begin(project, request("run-adopt", "agent-adopt")); + long starts = AgentCoordination.readLog(project).stream().filter(line -> line.contains("\tstart\t")).count(); + assertEquals(1, starts); + } + + @Test + void unsafeIdsAndTheSharedInboxNameAreRejected() { + assertThrows(IllegalArgumentException.class, () -> AgentCoordination.begin(project, request("../escape", "agent-a"))); + assertThrows(IllegalArgumentException.class, () -> AgentCoordination.begin(project, request("run-ok", "any"))); + } + + @Test + void localBrowserProfileUsesTheCurrentRunAndDoesNotReplaceAnExistingDirectory() throws Exception { + ChromeOptions idle = new ChromeOptions(); + DriverConstruction.applyRunBrowserProfile(idle); + assertFalse(argumentText(idle).contains("user-data-dir")); + + AgentCoordination.Run run = AgentCoordination.begin(project, request("run-browser", "agent-browser")); + ChromeOptions local = new ChromeOptions(); + DriverConstruction.applyRunBrowserProfile(local); + assertTrue(argumentText(local).contains("--user-data-dir=" + run.browserProfileDirectory().toAbsolutePath().normalize())); + + ChromeOptions preset = new ChromeOptions(); + preset.addArguments("--user-data-dir=/tmp/already-mine"); + DriverConstruction.applyRunBrowserProfile(preset); + String text = argumentText(preset); + assertTrue(text.contains("--user-data-dir=/tmp/already-mine")); + assertFalse(text.contains(run.browserProfileDirectory().toString())); + assertEquals(1, text.split("user-data-dir", -1).length - 1); + } + + private void appendMany(String agent, int count, CountDownLatch ready, CountDownLatch go) { + ready.countDown(); + try { + go.await(); + for (int index = 0; index < count; index++) { + AgentCoordination.appendLog( + project, + Instant.now(), + agent, + "run-" + agent, + "note", + "line-" + index, + project.resolve("record.json") + ); + } + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + + private static AgentCoordination.Request request(String runId, String agentId) { + return AgentCoordination.Request.of(runId, agentId, null, null, null, null, "test"); + } + + private static String argumentText(ChromeOptions options) { + Object raw = options.getCapability("goog:chromeOptions"); + if (!(raw instanceof java.util.Map map)) return ""; + Object args = map.get("args"); + if (!(args instanceof List list)) return ""; + List text = new ArrayList<>(); + for (Object arg : list) { + if (arg != null) text.add(arg.toString()); + } + return String.join(" ", text); + } +} From 26559f609a906d1fb3d039b83f93a94c629716a2 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 18:30:19 +0000 Subject: [PATCH 06/23] Bottom line: discover, confirm, and isolate record the run, and two headless sessions do not share one window. The same launcher exposes short-log, note, inbox, and finish. An already-open Workbench window stays with one agent. Other isolates stay headless on their own session file under the run directory. --- .../workbench/WorkbenchApplication.java | 70 +++++- .../workbench/mcp/WorkbenchAttachServer.java | 18 +- .../workbench/WorkbenchApplicationTest.java | 18 ++ .../launcher/WorkbenchAgentCommands.java | 217 +++++++++++++++-- .../dscode/launcher/WorkbenchCommandLine.java | 225 ++++++++++++++++-- .../launcher/WorkbenchSessionCommands.java | 153 ++++++++---- .../launcher/WorkbenchAgentCommandsTest.java | 54 +++++ .../launcher/WorkbenchCommandLineTest.java | 36 +++ .../WorkbenchSessionCommandsTest.java | 79 +++++- 9 files changed, 771 insertions(+), 99 deletions(-) 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 27f34b8c4..68c0dda7e 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java @@ -21,6 +21,7 @@ import java.io.InputStream; import java.io.PrintStream; import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.CountDownLatch; @@ -241,13 +242,19 @@ static int session(String[] args, PrintStream out, PrintStream err) { IsolateArgs parsed = projectCommandArgs(args, "session"); Map workerProperties; try { - workerProperties = tools.dscode.workbench.discover.LastDiscoverSnapshot.workerSystemProperties( + workerProperties = new LinkedHashMap<>(tools.dscode.workbench.discover.LastDiscoverSnapshot.workerSystemProperties( parsed.project(), parsed.tags(), parsed.name(), parsed.example() - ); + )); } catch (RuntimeException failure) { err.println(failure.getMessage()); return 1; } + if (parsed.runId() != null && !parsed.runId().isBlank()) { + workerProperties.put("pkb_run_id", parsed.runId()); + } + if (parsed.agentId() != null && !parsed.agentId().isBlank()) { + workerProperties.put("pkb_agent_id", parsed.agentId()); + } CountDownLatch done = new CountDownLatch(1); AtomicBoolean stopping = new AtomicBoolean(); @@ -271,7 +278,10 @@ static int session(String[] args, PrintStream out, PrintStream err) { } done.countDown(); }; - server = WorkbenchAttachServer.startCliSession(controller, parsed.project(), stop); + Path stateFile = parsed.sessionFile() == null + ? WorkbenchAttachServer.cliSessionStateFile(parsed.project()) + : parsed.sessionFile(); + server = WorkbenchAttachServer.startCliSession(controller, parsed.project(), stop, stateFile); Runtime.getRuntime().addShutdownHook(new Thread(stop, "pickleball-workbench-session-shutdown")); out.println("Workbench CLI session: pid=" + ProcessHandle.current().pid() + " url=" + server.url()); @@ -323,7 +333,15 @@ static boolean stdinIsInteractiveTty() { } } - record IsolateArgs(Path project, String tags, String name, String example) { + record IsolateArgs( + Path project, + String tags, + String name, + String example, + Path sessionFile, + String runId, + String agentId + ) { } static IsolateArgs projectCommandArgs(String[] args, String command) { @@ -331,11 +349,15 @@ static IsolateArgs projectCommandArgs(String[] args, String command) { throw new IllegalArgumentException( "Usage: pickleball-workbench " + command + " [--tags ] [--name ] [--example ]" + + " [--session-file ] [--run-id ] [--agent-id ]" ); } String tags = null; String name = null; String example = null; + String sessionFile = null; + String runId = null; + String agentId = null; boolean absorbName = false; boolean absorbExample = false; for (int index = 2; index < args.length; index++) { @@ -362,13 +384,45 @@ static IsolateArgs projectCommandArgs(String[] args, String command) { example = value == null || value.isBlank() ? null : value; absorbExample = example != null; absorbName = false; + } else if (token.startsWith("--session-file=")) { + sessionFile = token.substring("--session-file=".length()); + absorbName = false; + absorbExample = false; + } else if ("--session-file".equals(token) && index + 1 < args.length) { + sessionFile = args[++index]; + absorbName = false; + absorbExample = false; + } else if (token.startsWith("--run-id=")) { + runId = token.substring("--run-id=".length()); + absorbName = false; + absorbExample = false; + } else if ("--run-id".equals(token) && index + 1 < args.length) { + runId = args[++index]; + absorbName = false; + absorbExample = false; + } else if (token.startsWith("--agent-id=") || token.startsWith("--agent=")) { + agentId = token.substring(token.indexOf('=') + 1); + absorbName = false; + absorbExample = false; + } else if (("--agent-id".equals(token) || "--agent".equals(token)) && index + 1 < args.length) { + agentId = args[++index]; + absorbName = false; + absorbExample = false; } else if (absorbExample && example != null && !token.startsWith("-")) { example = example + " " + token; } else if (absorbName && name != null && !token.startsWith("-")) { name = name + " " + token; } } - return new IsolateArgs(Path.of(args[1]), tags, name, example); + return new IsolateArgs( + Path.of(args[1]), + tags, + name, + example, + sessionFile == null || sessionFile.isBlank() ? null : Path.of(sessionFile), + runId == null || runId.isBlank() ? null : runId.trim(), + agentId == null || agentId.isBlank() ? null : agentId.trim() + ); } private static int workerCheck(String[] args, PrintStream out) { @@ -623,7 +677,7 @@ private static void printUsage(PrintStream out) { out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication worker-check "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication live-check "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication isolate [--tags ] [--name ] [--example ]"); - out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication session [--tags ] [--name ] [--example ]"); + out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication session [--tags ] [--name ] [--example ] [--session-file ] [--run-id ] [--agent-id ]"); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication mcp "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication ui "); out.println(" java -cp : tools.dscode.workbench.WorkbenchApplication --version"); @@ -633,8 +687,8 @@ 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("isolate holds a paused worker from the last Discover snapshot when stdin is an interactive TTY, or when pickleball.workbench.isolate.once is set."); - out.println("session is the headless long-lived CLI controller: sync, start the Discover-snapshot worker, and serve 127.0.0.1 HTTP plus the same session commands the window uses. State is .pickleball/v//workbench/cli-session.json when current.json is complete."); - out.println("Consumer agents start session through PickleballWorkbenchLauncher isolate/session-start (detached) when nobody is watching. When a window is already open, those same commands drive that session. Do not start ui for agents."); + out.println("session is the headless long-lived CLI controller: sync, start the Discover-snapshot worker, and serve 127.0.0.1 HTTP plus the same session commands the window uses. State is .pickleball/v//workbench/cli-session.json when current.json is complete, unless --session-file names that run's own session file. --run-id and --agent-id are copied onto the worker as pkb_run_id and pkb_agent_id. Two headless sessions do not share one session file."); + out.println("Consumer agents start session through PickleballWorkbenchLauncher isolate/session-start (detached) when nobody is watching. When a window is already open, only one agent drives it. Other agents stay headless on their own run ids. Do not start ui for agents."); out.println("mcp serves the same Workbench services over protocol-only stdio; optional host wiring, not an agent setup step."); 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 (versioned under v// when current.json is complete). The window calls the session commands; it does not keep a second copy of them."); } 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 index b33aeba07..3dd0033fc 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchAttachServer.java @@ -84,7 +84,23 @@ public static WorkbenchAttachServer startCliSession( Path projectRoot, Runnable stopHandler ) { - return startCliSession(services, projectRoot, stopHandler, null); + return startCliSession(services, projectRoot, stopHandler, (Path) null); + } + + public static WorkbenchAttachServer startCliSession( + WorkbenchServices services, + Path projectRoot, + Runnable stopHandler, + Path stateFile + ) { + return start( + services, + projectRoot, + CLI_SESSION_MODE, + stateFile == null ? cliSessionStateFile(projectRoot) : stateFile, + stopHandler, + null + ); } static WorkbenchAttachServer startCliSession( diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java index 9b64f5938..2d15bdd35 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchApplicationTest.java @@ -220,6 +220,24 @@ void projectCommandArgsForwardsExampleWithoutAbsorbingItIntoName() { assertEquals("1 2 5", parsed.example()); } + @Test + void projectCommandArgsKeepsSessionIdentityOffTheName() { + WorkbenchApplication.IsolateArgs parsed = WorkbenchApplication.projectCommandArgs( + new String[]{ + "session", "C:/consumer", + "--name=The", "failing", "scenario", + "--session-file", "C:/consumer/.pickleball/runs/run-1/session/cli-session.json", + "--run-id", "run-1", + "--agent-id", "agent-1" + }, + "session" + ); + assertEquals("The failing scenario", parsed.name()); + assertEquals("run-1", parsed.runId()); + assertEquals("agent-1", parsed.agentId()); + assertTrue(parsed.sessionFile().toString().replace('\\', '/').endsWith("runs/run-1/session/cli-session.json")); + } + private static Output run(String... args) { ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); diff --git a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java index 54a997cfb..5511c9b16 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java @@ -1,5 +1,6 @@ package tools.dscode.launcher; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.reporting.diagnostic.AgentDiscoverPlanner; import tools.dscode.common.reporting.diagnostic.ConsumerMavenTestRunner; import tools.dscode.common.reporting.diagnostic.LastDiscoverSnapshot; @@ -9,6 +10,7 @@ import java.io.IOException; import java.io.PrintStream; import java.nio.file.Path; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -42,6 +44,10 @@ static int run( case "resolve-runvars" -> resolveRunVars(parsed, out); case "discover" -> discover(parsed, out, err, maven); case "confirm" -> confirm(parsed, out, err, maven); + case "short-log" -> shortLog(parsed, out, err); + case "note" -> note(parsed, out, err); + case "inbox" -> inbox(parsed, out, err); + case "finish" -> finish(parsed, out, err); default -> { err.println("Unknown Workbench agent command: " + parsed.command()); yield 2; @@ -70,7 +76,8 @@ private static int hint(WorkbenchCommandLine.Parsed parsed, PrintStream out) { out.println(); printDryRunResolve(parsed.project(), plan.runVars(), false, out); out.println("Browser: " + plan.browser().browser() + " (" + plan.browser().reason() + ")."); - out.println("Multi-scenario Discover/Confirm use this high pkb_parallel. When a Workbench window is already open, drive that session with open-scenario, example, play, execute-step, stop, and diagnostic-run so the person sees it. Otherwise isolate starts a headless session; then use execute-step / status / events / stop. Do not start the GUI. Same launcher, only change exec.args."); + out.println("Multi-scenario Discover/Confirm use this high pkb_parallel. When a Workbench window is already open, only one agent drives that session with open-scenario, example, play, execute-step, stop, and diagnostic-run so the person sees it. Other agents stay headless on their own run ids. Otherwise isolate starts a headless session; then use execute-step / status / events / stop. Do not start the GUI. Same launcher, only change exec.args."); + out.println("Read the short log (.pickleball/agent-log) before and after a run, and during a long session, instead of the dense diagnostic log."); out.println("After Discover, confirm (and isolate/execute-step for live debug) replay the retained pkb_run_profile through pkb_runvars. Never supply pkb_run_profile as input."); out.println("Sealed runs are opt-in: resolve → inspect → complete map including the six context keys → pkb_overriderunvars. Do not mix with pkb_runvars or pkb_profile. Compare runProfileFingerprint after the sealed run."); out.println(); @@ -128,14 +135,34 @@ private static int discover( PrintStream err, MavenRunner maven ) { - AgentDiscoverPlanner.Plan plan = AgentDiscoverPlanner.discover( - parsed.project(), parsed.tags(), parsed.name(), parsed.retention(), parsed.example() + AgentCoordination.Run run = AgentCoordination.begin( + parsed.project(), + request(parsed, "discover") ); - out.println("Workbench discover " + plan.browser().reason() + "."); - out.println("pkb_runvars=" + plan.runVars()); - List command = ConsumerMavenTestRunner.command(parsed.project(), plan.runVars()); - int exit = maven.run(parsed.project(), command, out, err); - return recordDiscover(parsed.project(), out, err, exit); + try { + AgentDiscoverPlanner.Plan plan = AgentDiscoverPlanner.discover( + parsed.project(), parsed.tags(), parsed.name(), parsed.retention(), parsed.example() + ); + out.println("Workbench discover " + plan.browser().reason() + "."); + out.println("pkb_runvars=" + plan.runVars()); + List command = withCoordination( + ConsumerMavenTestRunner.command(parsed.project(), plan.runVars()), + run + ); + int exit = maven.run(parsed.project(), command, out, err); + AgentCoordination.finish( + parsed.project(), + run.runId(), + parsed.coordination().learned(), + exit == 0 ? "PASSED" : "FAILED", + java.time.Instant.now(), + out + ); + return recordDiscover(parsed.project(), out, err, exit); + } catch (RuntimeException failure) { + finishQuietly(parsed.project(), run.runId(), "FAILED", out); + throw failure; + } } private static int confirm( @@ -144,16 +171,172 @@ private static int confirm( PrintStream err, MavenRunner maven ) { - LastDiscoverSnapshot.Snapshot snapshot = LastDiscoverSnapshot.require(parsed.project()); - Map retained = LastDiscoverSnapshot.retainedRunVars(snapshot); - String runVars = AgentDiscoverPlanner.confirmRunVars( - retained, parsed.tags(), parsed.name(), parsed.retention(), parsed.example() + AgentCoordination.Run run = AgentCoordination.begin( + parsed.project(), + request(parsed, "confirm") + ); + try { + LastDiscoverSnapshot.Snapshot snapshot = LastDiscoverSnapshot.require(parsed.project()); + Map retained = LastDiscoverSnapshot.retainedRunVars(snapshot); + String runVars = AgentDiscoverPlanner.confirmRunVars( + retained, parsed.tags(), parsed.name(), parsed.retention(), parsed.example() + ); + out.println("Workbench confirm replaying Discover snapshot as pkb_runvars."); + out.println("pkb_runvars=" + runVars); + List command = withCoordination( + ConsumerMavenTestRunner.confirmCommand(parsed.project(), runVars), + run + ); + int exit = maven.run(parsed.project(), command, out, err); + AgentCoordination.finish( + parsed.project(), + run.runId(), + parsed.coordination().learned(), + exit == 0 ? "PASSED" : "FAILED", + java.time.Instant.now(), + out + ); + return printCatalog(parsed.project(), out, err, exit, "workbench-confirm", false); + } catch (RuntimeException failure) { + finishQuietly(parsed.project(), run.runId(), "FAILED", out); + throw failure; + } + } + + private static int shortLog(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + int limit = parsed.coordination().limit() == null ? 40 : parsed.coordination().limit(); + try { + java.util.List lines = AgentCoordination.recentLog(parsed.project(), limit); + if (lines.isEmpty()) { + out.println("(no short-log lines)"); + return 0; + } + for (String line : lines) out.println(line); + return 0; + } catch (java.io.IOException failure) { + err.println("Could not read the short log: " + failure.getMessage()); + return 1; + } + } + + private static int note(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + String text = firstText(parsed); + if (text == null) { + err.println("Usage: note --text= [--agent=] [--run-id=]"); + return 2; + } + AgentCoordination.note( + parsed.project(), + parsed.coordination().agentId(), + parsed.coordination().runId(), + text, + recordPath(parsed), + out ); - out.println("Workbench confirm replaying Discover snapshot as pkb_runvars."); - out.println("pkb_runvars=" + runVars); - List command = ConsumerMavenTestRunner.confirmCommand(parsed.project(), runVars); - int exit = maven.run(parsed.project(), command, out, err); - return printCatalog(parsed.project(), out, err, exit, "workbench-confirm", false); + return 0; + } + + private static int inbox(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + WorkbenchCommandLine.Coordination flags = parsed.coordination(); + boolean write = flags.inboxWrite() || (flags.text() != null && !flags.inboxList() && !flags.inboxTake()); + boolean take = flags.inboxTake(); + boolean list = flags.inboxList() || (!write && !take); + if (write) { + String message = firstText(parsed); + if (message == null) { + err.println("Usage: inbox --write --to= --text= [--from=] [--run-id=]"); + return 2; + } + String from = flags.inboxFrom() != null ? flags.inboxFrom() : flags.agentId(); + java.nio.file.Path file = AgentCoordination.writeInbox( + parsed.project(), + flags.inboxTo(), + from, + message, + flags.runId() == null ? null : recordPath(parsed) == null ? null : recordPath(parsed).toString() + ); + out.println("inbox=" + file); + return 0; + } + String agent = flags.agentId() != null ? flags.agentId() : flags.inboxTo(); + if (agent == null || agent.isBlank()) { + err.println("Usage: inbox --list|--take --agent="); + return 2; + } + java.util.List notes = take + ? AgentCoordination.takeInbox(parsed.project(), agent) + : AgentCoordination.listInbox(parsed.project(), agent); + if (notes.isEmpty()) { + out.println("(no inbox notes)"); + return 0; + } + for (AgentCoordination.InboxNote note : notes) { + out.println(note.at() + "\t" + note.from() + "\t" + note.message() + + (note.record() == null ? "" : "\t" + note.record())); + } + return list || take ? 0 : 2; + } + + private static int finish(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + String runId = parsed.coordination().runId(); + if (runId == null || runId.isBlank()) { + err.println("Usage: finish --run-id= [--learned=] [--status=]"); + return 2; + } + try { + AgentCoordination.finish( + parsed.project(), + runId, + parsed.coordination().learned(), + "STOPPED", + java.time.Instant.now(), + out + ); + return 0; + } catch (RuntimeException failure) { + err.println(failure.getMessage()); + return 1; + } + } + + private static AgentCoordination.Request request(WorkbenchCommandLine.Parsed parsed, String purpose) { + WorkbenchCommandLine.Coordination flags = parsed.coordination(); + return AgentCoordination.Request.of( + flags.runId(), + flags.agentId(), + flags.group(), + flags.sequence(), + flags.who(), + flags.why(), + purpose + ); + } + + private static List withCoordination(List command, AgentCoordination.Run run) { + List copy = new ArrayList<>(command); + copy.addAll(AgentCoordination.mavenProperties(run)); + return List.copyOf(copy); + } + + private static void finishQuietly(Path project, String runId, String status, PrintStream out) { + try { + AgentCoordination.finish(project, runId, null, status, java.time.Instant.now(), out); + } catch (RuntimeException ignored) { + // The original failure is the one to report. + } + } + + private static String firstText(WorkbenchCommandLine.Parsed parsed) { + WorkbenchCommandLine.Coordination flags = parsed.coordination(); + if (flags.text() != null && !flags.text().isBlank()) return flags.text(); + if (flags.why() != null && !flags.why().isBlank()) return flags.why(); + return null; + } + + private static Path recordPath(WorkbenchCommandLine.Parsed parsed) { + String runId = parsed.coordination().runId(); + if (runId == null || runId.isBlank()) return null; + return AgentCoordination.runDirectory(parsed.project(), runId).resolve(AgentCoordination.RECORD_FILE); } private static int recordDiscover(Path project, PrintStream out, PrintStream err, int mavenExit) { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java index 5ef4c8f38..828780c7c 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java @@ -15,7 +15,8 @@ final class WorkbenchCommandLine { "sync", "worker-check", "live-check", "ui", "mcp", "isolate", "session-start", "session" ); static final Set AGENT_CORE_COMMANDS = Set.of( - "export-guidance", "hint", "discover-hint", "discover", "confirm", "resolve-runvars" + "export-guidance", "hint", "discover-hint", "discover", "confirm", "resolve-runvars", + "short-log", "note", "inbox", "finish" ); static final Set SESSION_CLIENT_COMMANDS = Set.of( "isolate", "session-start", "execute-step", "status", "events", "stop", "kill", @@ -27,6 +28,27 @@ final class WorkbenchCommandLine { private WorkbenchCommandLine() { } + record Coordination( + String runId, + String agentId, + String group, + Integer sequence, + String who, + String why, + String learned, + String text, + String inboxTo, + String inboxFrom, + boolean inboxWrite, + boolean inboxList, + boolean inboxTake, + Integer limit + ) { + static Coordination empty() { + return new Coordination(null, null, null, null, null, null, null, null, null, null, false, false, false, null); + } + } + record Parsed( String command, Path project, @@ -35,7 +57,8 @@ record Parsed( String name, String example, String retention, - String[] forwarded + String[] forwarded, + Coordination coordination ) { } @@ -54,22 +77,35 @@ static boolean isSessionClientCommand(String command) { static Parsed parse(String[] args) { Path cwd = Path.of("").toAbsolutePath().normalize(); if (args == null || args.length == 0) { - return new Parsed("ui", cwd, null, null, null, null, null, new String[]{"ui", cwd.toString()}); + return new Parsed("ui", cwd, null, null, null, null, null, new String[]{"ui", cwd.toString()}, Coordination.empty()); } String command = args[0]; if ("export-guidance".equals(command)) { Path output = args.length >= 2 && !isFlag(args[1]) ? Path.of(args[1]) : Path.of(".pickleball"); - return new Parsed(command, cwd, output, null, null, null, null, args.clone()); + return new Parsed(command, cwd, output, null, null, null, null, args.clone(), Coordination.empty()); } String tags = null; String name = null; String example = null; String retention = null; - boolean absorbName = false; - boolean absorbExample = false; + String runId = null; + String agentId = null; + String group = null; + Integer sequence = null; + String who = null; + String why = null; + String learned = null; + String text = null; + String inboxTo = null; + String inboxFrom = null; + boolean inboxWrite = false; + boolean inboxList = false; + boolean inboxTake = false; + Integer limit = null; + String absorb = null; Path project = null; List rest = new ArrayList<>(); for (int index = 1; index < args.length; index++) { @@ -85,26 +121,22 @@ static Parsed parse(String[] args) { } if (token.startsWith("--name=")) { name = token.substring("--name=".length()); - absorbName = true; - absorbExample = false; + absorb = "name"; continue; } if ("--name".equals(token) && index + 1 < args.length) { name = args[++index]; - absorbName = true; - absorbExample = false; + absorb = "name"; continue; } if (token.startsWith("--example=")) { example = blankToNull(token.substring("--example=".length())); - absorbExample = example != null; - absorbName = false; + absorb = example == null ? null : "example"; continue; } if ("--example".equals(token) && index + 1 < args.length) { example = blankToNull(args[++index]); - absorbExample = example != null; - absorbName = false; + absorb = example == null ? null : "example"; continue; } if (token.startsWith("--retention=")) { @@ -116,18 +148,147 @@ static Parsed parse(String[] args) { retention = parseRetention(value); continue; } - if (isFlag(token)) { - rest.add(token); + if (token.startsWith("--run-id=")) { + runId = blankToNull(token.substring("--run-id=".length())); + absorb = null; + continue; + } + if ("--run-id".equals(token) && index + 1 < args.length) { + runId = blankToNull(args[++index]); + absorb = null; + continue; + } + if (token.startsWith("--agent=") || token.startsWith("--agent-id=")) { + agentId = blankToNull(token.substring(token.indexOf('=') + 1)); + absorb = null; + continue; + } + if (("--agent".equals(token) || "--agent-id".equals(token)) && index + 1 < args.length) { + agentId = blankToNull(args[++index]); + absorb = null; + continue; + } + if (token.startsWith("--group=")) { + group = blankToNull(token.substring("--group=".length())); + absorb = null; + continue; + } + if ("--group".equals(token) && index + 1 < args.length) { + group = blankToNull(args[++index]); + absorb = null; + continue; + } + if (token.startsWith("--sequence=")) { + sequence = parseWholeNumber(token.substring("--sequence=".length()), "sequence"); + absorb = null; + continue; + } + if ("--sequence".equals(token) && index + 1 < args.length) { + sequence = parseWholeNumber(args[++index], "sequence"); + absorb = null; + continue; + } + if (token.startsWith("--who=")) { + who = blankToNull(token.substring("--who=".length())); + absorb = who == null ? null : "who"; + continue; + } + if ("--who".equals(token) && index + 1 < args.length) { + who = blankToNull(args[++index]); + absorb = who == null ? null : "who"; + continue; + } + if (token.startsWith("--why=")) { + why = blankToNull(token.substring("--why=".length())); + absorb = why == null ? null : "why"; + continue; + } + if ("--why".equals(token) && index + 1 < args.length) { + why = blankToNull(args[++index]); + absorb = why == null ? null : "why"; + continue; + } + if (token.startsWith("--learned=")) { + learned = blankToNull(token.substring("--learned=".length())); + absorb = learned == null ? null : "learned"; continue; } - // Maven exec splits unquoted --name=The failing scenario and --example=1 2 5 - // into extra tokens. The selector set most recently absorbs them. - if (absorbExample && example != null && !looksLikeProject(token)) { - example = example + " " + token; + if ("--learned".equals(token) && index + 1 < args.length) { + learned = blankToNull(args[++index]); + absorb = learned == null ? null : "learned"; continue; } - if (absorbName && name != null && !looksLikeProject(token)) { - name = name + " " + token; + if (token.startsWith("--text=")) { + text = blankToNull(token.substring("--text=".length())); + absorb = text == null ? null : "text"; + continue; + } + if ("--text".equals(token) && index + 1 < args.length) { + text = blankToNull(args[++index]); + absorb = text == null ? null : "text"; + continue; + } + if (token.startsWith("--to=")) { + inboxTo = blankToNull(token.substring("--to=".length())); + absorb = null; + continue; + } + if ("--to".equals(token) && index + 1 < args.length) { + inboxTo = blankToNull(args[++index]); + absorb = null; + continue; + } + if (token.startsWith("--from=")) { + inboxFrom = blankToNull(token.substring("--from=".length())); + absorb = null; + continue; + } + if ("--from".equals(token) && index + 1 < args.length) { + inboxFrom = blankToNull(args[++index]); + absorb = null; + continue; + } + if (token.startsWith("--limit=")) { + limit = parseWholeNumber(token.substring("--limit=".length()), "limit"); + absorb = null; + continue; + } + if ("--limit".equals(token) && index + 1 < args.length) { + limit = parseWholeNumber(args[++index], "limit"); + absorb = null; + continue; + } + if ("--write".equals(token)) { + inboxWrite = true; + absorb = null; + continue; + } + if ("--list".equals(token)) { + inboxList = true; + absorb = null; + continue; + } + if ("--take".equals(token)) { + inboxTake = true; + absorb = null; + continue; + } + if (isFlag(token)) { + rest.add(token); + continue; + } + // Maven exec splits unquoted free-text flags into extra tokens. + // The selector set most recently absorbs them. + if (absorb != null && !looksLikeProject(token)) { + switch (absorb) { + case "example" -> example = example + " " + token; + case "name" -> name = name + " " + token; + case "who" -> who = who + " " + token; + case "why" -> why = why + " " + token; + case "learned" -> learned = learned + " " + token; + case "text" -> text = text + " " + token; + default -> rest.add(token); + } continue; } if (project == null && looksLikeProject(token)) { @@ -137,6 +298,10 @@ static Parsed parse(String[] args) { rest.add(token); } if (project == null) project = cwd; + Coordination coordination = new Coordination( + runId, agentId, group, sequence, who, why, learned, text, + inboxTo, inboxFrom, inboxWrite, inboxList, inboxTake, limit + ); if (isForwardedCommand(command)) { List forwarded = new ArrayList<>(); @@ -156,10 +321,22 @@ static Parsed parse(String[] args) { } forwarded.addAll(rest); return new Parsed( - command, project, null, tags, name, example, retention, forwarded.toArray(String[]::new) + command, project, null, tags, name, example, retention, forwarded.toArray(String[]::new), + coordination ); } - return new Parsed(command, project, null, tags, name, example, retention, args.clone()); + return new Parsed(command, project, null, tags, name, example, retention, args.clone(), coordination); + } + + private static Integer parseWholeNumber(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " requires a whole number."); + } + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException(label + " must be a whole number: " + value); + } } private static String blankToNull(String value) { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java index 15ce04a52..4c365336d 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.control.protocol.PickleballLocalLayout; import java.io.IOException; @@ -13,6 +14,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -33,9 +35,21 @@ public final class WorkbenchSessionCommands { private WorkbenchSessionCommands() { } + record SessionLaunch( + Path project, + String tags, + String name, + String example, + Path logFile, + Path sessionFile, + String runId, + String agentId + ) { + } + @FunctionalInterface interface DetachedStarter { - Process start(Path project, String tags, String name, String example, Path logFile) throws IOException; + Process start(SessionLaunch launch) throws IOException; } public static int run(String[] args, PrintStream out, PrintStream err) { @@ -91,32 +105,38 @@ static int run( } } - static Process startControllerSession( - Path project, - String tags, - String name, - String example, - Path logFile - ) throws IOException { - Path controllerJar = PickleballWorkbenchLauncher.extractEmbeddedPayload(project); + static Process startControllerSession(SessionLaunch launch) throws IOException { + Path controllerJar = PickleballWorkbenchLauncher.extractEmbeddedPayload(launch.project()); List forwarded = new ArrayList<>(); forwarded.add("session"); - forwarded.add(project.toString()); - if (tags != null && !tags.isBlank()) { + forwarded.add(launch.project().toString()); + if (launch.tags() != null && !launch.tags().isBlank()) { forwarded.add("--tags"); - forwarded.add(tags); + forwarded.add(launch.tags()); } - if (name != null && !name.isBlank()) { + if (launch.name() != null && !launch.name().isBlank()) { forwarded.add("--name"); - forwarded.add(name); + forwarded.add(launch.name()); } - if (example != null && !example.isBlank()) { + if (launch.example() != null && !launch.example().isBlank()) { forwarded.add("--example"); - forwarded.add(example); + forwarded.add(launch.example()); + } + if (launch.sessionFile() != null) { + forwarded.add("--session-file"); + forwarded.add(launch.sessionFile().toString()); + } + if (launch.runId() != null && !launch.runId().isBlank()) { + forwarded.add("--run-id"); + forwarded.add(launch.runId()); } - return startDetached(project, PickleballWorkbenchLauncher.command( + if (launch.agentId() != null && !launch.agentId().isBlank()) { + forwarded.add("--agent-id"); + forwarded.add(launch.agentId()); + } + return startDetached(launch.project(), PickleballWorkbenchLauncher.command( controllerJar, forwarded.toArray(String[]::new) - ), logFile); + ), launch.logFile()); } static Process startDetached(Path project, List command, Path logFile) throws IOException { @@ -138,31 +158,55 @@ private static int sessionStart( HttpClient http ) { Path project = parsed.project(); + AgentCoordination.Run run = AgentCoordination.begin( + project, + AgentCoordination.Request.of( + parsed.coordination().runId(), + parsed.coordination().agentId(), + parsed.coordination().group(), + parsed.coordination().sequence(), + parsed.coordination().who(), + parsed.coordination().why(), + "isolate" + ) + ); Optional existing = readHealthy(project, http); if (existing.isPresent()) { SessionState state = existing.get(); out.println("ACK SESSION already-running pid=" + state.pid() + " url=" + state.url()); + out.println("Only one agent drives an open Workbench window. This run stays headless on run-id=" + + run.runId() + "."); return 0; } - Path logFile = PickleballLocalLayout.workbenchStateRoot(project).resolve("session.log"); + Path logFile = run.sessionDirectory().resolve("session.log"); + Path sessionFile = run.sessionStateFile(); Process process; try { - process = starter.start(project, parsed.tags(), parsed.name(), parsed.example(), logFile); + process = starter.start(new SessionLaunch( + project, + parsed.tags(), + parsed.name(), + parsed.example(), + logFile, + sessionFile, + run.runId(), + run.agentId() + )); } catch (IOException failure) { throw new IllegalStateException("Could not start Workbench session: " + failure.getMessage(), failure); } long deadline = System.nanoTime() + HEALTH_TIMEOUT.toNanos(); while (System.nanoTime() < deadline) { - Optional ready = readHealthy(project, http); + Optional ready = readHealthyFile(sessionFile, project, http); if (ready.isPresent()) { SessionState state = ready.get(); out.println("ACK SESSION pid=" + state.pid() + " url=" + state.url()); return 0; } if (process != null && !process.isAlive()) { - Optional afterExit = readHealthy(project, http); + Optional afterExit = readHealthyFile(sessionFile, project, http); if (afterExit.isPresent()) { SessionState state = afterExit.get(); out.println("ACK SESSION pid=" + state.pid() + " url=" + state.url()); @@ -184,7 +228,7 @@ private static int executeStep( PrintStream err, HttpClient http ) { - SessionState state = requireSession(parsed.project(), http); + SessionState state = requireSession(parsed, http); String text = flags.text; if (text == null || text.isBlank()) { err.println("Usage: execute-step or execute-step --text= [--ack-only]"); @@ -206,7 +250,7 @@ private static int textCommand( err.println("Usage: " + parsed.command() + " --text="); return 2; } - SessionState state = requireSession(parsed.project(), http); + SessionState state = requireSession(parsed, http); return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); } @@ -223,7 +267,7 @@ private static int openScenario( err.println("Usage: open-scenario --feature= --name= [--example=]"); return 2; } - SessionState state = requireSession(parsed.project(), http); + SessionState state = requireSession(parsed, http); return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); } @@ -240,7 +284,7 @@ private static int example( err.println("Usage: example --example="); return 2; } - SessionState state = requireSession(parsed.project(), http); + SessionState state = requireSession(parsed, http); Map body = commandBody(parsed, flags); body.put("example", selector); return postAndMaybeWait(state, flags, body, out, err, http); @@ -257,7 +301,7 @@ private static int diagnosticRun( err.println("Usage: diagnostic-run --run="); return 2; } - SessionState state = requireSession(parsed.project(), http); + SessionState state = requireSession(parsed, http); return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); } @@ -268,7 +312,7 @@ private static int postCommand( PrintStream err, HttpClient http ) { - SessionState state = requireSession(parsed.project(), http); + SessionState state = requireSession(parsed, http); return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); } @@ -308,7 +352,7 @@ private static int status( PrintStream err, HttpClient http ) { - Optional state = readHealthy(parsed.project(), http); + Optional state = resolveSession(parsed, http); if (state.isEmpty()) { err.println("No healthy Workbench session. Run isolate / session-start, or use the window that is already open."); return 1; @@ -337,7 +381,7 @@ private static int events( PrintStream err, HttpClient http ) { - SessionState state = requireSession(parsed.project(), http); + SessionState state = requireSession(parsed, http); try { JsonNode events = postJson(http, state, "/tools/workbench_events", Map.of()); out.println(events.toString()); @@ -354,9 +398,10 @@ private static int stop( PrintStream err, HttpClient http ) { - Optional state = readHealthy(parsed.project(), http); + Optional state = resolveSession(parsed, http); if (state.isEmpty()) { out.println("ACK SESSION already-stopped"); + finishRequestedRun(parsed, out); return 0; } SessionState session = state.get(); @@ -371,12 +416,14 @@ private static int stop( } if (watched) { out.println("ACK SESSION playback-stopped pid=" + session.pid()); + finishRequestedRun(parsed, out); return 0; } long deadline = System.nanoTime() + Duration.ofSeconds(8).toNanos(); while (System.nanoTime() < deadline) { - if (readHealthy(parsed.project(), http).isEmpty() && !pidAlive(session.pid())) { + if (readHealthyFile(session.stateFile(), parsed.project(), http).isEmpty() && !pidAlive(session.pid())) { out.println("ACK SESSION stopped pid=" + session.pid()); + finishRequestedRun(parsed, out); return 0; } sleep(POLL); @@ -385,14 +432,28 @@ private static int stop( sleep(Duration.ofMillis(400)); if (pidAlive(session.pid())) ProcessHandle.of(session.pid()).ifPresent(ProcessHandle::destroyForcibly); try { - Files.deleteIfExists(sessionFile(parsed.project())); + Files.deleteIfExists(session.stateFile()); } catch (IOException ignored) { // Disposable session state. } out.println("ACK SESSION killed pid=" + session.pid()); + finishRequestedRun(parsed, out); return 0; } + private static void finishRequestedRun(WorkbenchCommandLine.Parsed parsed, PrintStream out) { + String runId = parsed.coordination().runId(); + if (runId == null || runId.isBlank()) return; + AgentCoordination.finish( + parsed.project(), + runId, + parsed.coordination().learned(), + "STOPPED", + Instant.now(), + out + ); + } + private static int waitForDone( HttpClient http, SessionState state, @@ -404,7 +465,7 @@ private static int waitForDone( while (true) { JsonNode view; try { - if (!pidAlive(state.pid()) && readHealthy(state.project(), http).isEmpty()) { + if (!pidAlive(state.pid()) && readHealthyFile(state.stateFile(), state.project(), http).isEmpty()) { err.println("DONE " + id + " TIMEOUT"); return 1; } @@ -436,12 +497,23 @@ private static int terminalExit(String status) { return "SUCCESS".equals(status) ? 0 : 1; } - private static SessionState requireSession(Path project, HttpClient http) { - return readHealthy(project, http).orElseThrow(() -> new IllegalStateException( + private static SessionState requireSession(WorkbenchCommandLine.Parsed parsed, HttpClient http) { + return resolveSession(parsed, http).orElseThrow(() -> new IllegalStateException( "No healthy Workbench session. Run isolate / session-start, or use the window that is already open." )); } + private static Optional resolveSession(WorkbenchCommandLine.Parsed parsed, HttpClient http) { + String runId = parsed.coordination().runId(); + if (runId != null && !runId.isBlank()) { + Path file = AgentCoordination.runDirectory(parsed.project(), runId) + .resolve("session") + .resolve("cli-session.json"); + return readHealthyFile(file, parsed.project(), http); + } + return readHealthy(parsed.project(), http); + } + static Optional readHealthy(Path project, HttpClient http) { Optional attached = readHealthyFile(PickleballLocalLayout.attachFile(project), project, http); if (attached.isPresent() && "ui-attach".equals(attached.get().mode())) { @@ -454,7 +526,7 @@ private static Optional readHealthyFile(Path file, Path project, H if (!Files.isRegularFile(file)) return Optional.empty(); try { JsonNode root = JSON.readTree(file.toFile()); - SessionState state = SessionState.from(project, root); + SessionState state = SessionState.from(project, file, root); if (!pidAlive(state.pid())) return Optional.empty(); JsonNode health = getJson(http, state, "/health"); if (!"ok".equals(textOr(health, "status", ""))) return Optional.empty(); @@ -532,14 +604,15 @@ private static HttpClient defaultHttp() { return HttpClient.newBuilder().connectTimeout(HTTP_TIMEOUT).build(); } - record SessionState(Path project, String url, String token, long pid, String mode) { - static SessionState from(Path project, JsonNode root) { + record SessionState(Path project, String url, String token, long pid, String mode, Path stateFile) { + static SessionState from(Path project, Path stateFile, JsonNode root) { return new SessionState( project.toAbsolutePath().normalize(), root.path("url").asText(""), root.path("token").asText(""), root.path("pid").asLong(0L), - root.path("mode").asText("") + root.path("mode").asText(""), + stateFile ); } } diff --git a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java index c66d7927f..e9631f364 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java @@ -1,7 +1,9 @@ package tools.dscode.launcher; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.reporting.diagnostic.LastDiscoverSnapshot; import java.io.ByteArrayOutputStream; @@ -21,6 +23,11 @@ class WorkbenchAgentCommandsTest { @TempDir Path tempDir; + @AfterEach + void clearCoordination() { + AgentCoordination.clearCurrent(); + } + @Test void hintPrintsLadderRunVarsAndNextDiscover() throws Exception { Path resources = tempDir.resolve("src/test/resources"); @@ -215,6 +222,53 @@ void confirmRequiresDiscoverSnapshot() { assertFalse(errors.toLowerCase().contains("register")); } + @Test + void shortLogNoteInboxAndFinishUseTheProjectBoard() throws Exception { + Output note = run("note", tempDir.toString(), "--agent=agent-board", "--text=looking at row 2"); + assertEquals(0, note.exitCode(), note.stderr()); + Output log = run("short-log", tempDir.toString()); + assertEquals(0, log.exitCode(), log.stderr()); + assertTrue(log.stdout().contains("agent-board")); + assertTrue(log.stdout().contains("looking at row 2")); + + Output write = run( + "inbox", tempDir.toString(), "--write", "--to=agent-other", + "--from=agent-board", "--text=window is taken" + ); + assertEquals(0, write.exitCode(), write.stderr()); + Output hidden = run("inbox", tempDir.toString(), "--list", "--agent=agent-board"); + assertEquals(0, hidden.exitCode(), hidden.stderr()); + assertTrue(hidden.stdout().contains("(no inbox notes)")); + Output listed = run("inbox", tempDir.toString(), "--list", "--agent=agent-other"); + assertTrue(listed.stdout().contains("window is taken")); + Output taken = run("inbox", tempDir.toString(), "--take", "--agent=agent-other"); + assertTrue(taken.stdout().contains("window is taken")); + Output empty = run("inbox", tempDir.toString(), "--list", "--agent=agent-other"); + assertTrue(empty.stdout().contains("(no inbox notes)")); + + Output discover = run( + "finish", tempDir.toString(), "--run-id=missing-run", "--learned=no such run" + ); + assertEquals(1, discover.exitCode()); + + AgentCoordination.begin(tempDir, AgentCoordination.Request.of( + "run-finish", "agent-board", null, null, null, null, "discover" + )); + Output finished = run( + "finish", tempDir.toString(), "--run-id=run-finish", "--learned=the empty row fails" + ); + assertEquals(0, finished.exitCode(), finished.stderr()); + String record = Files.readString( + AgentCoordination.runDirectory(tempDir, "run-finish").resolve("record.json") + ); + assertTrue(record.contains("the empty row fails")); + assertTrue(record.contains("STOPPED")); + String board = Files.readString(AgentCoordination.logFile(tempDir)); + assertTrue(board.contains("\tstart\t")); + assertTrue(board.contains("\tstop\t")); + assertTrue(board.contains("record.json")); + } + private Output run(String... args) { ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); diff --git a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java index 40e801292..bdb0ce3ec 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -94,6 +95,41 @@ void omittedRetentionStaysNull() { assertNull(parsed.retention()); } + @Test + void coordinationFlagsAreNotForwardedAndDoNotJoinTheName() { + WorkbenchCommandLine.Parsed parsed = WorkbenchCommandLine.parse(new String[]{ + "isolate", + "--name=The", "failing", "scenario", + "--run-id", "run-42", + "--agent", "agent-7", + "--group=wave", + "--sequence=4", + "--who=Dan", "the", "author", + "--why=check", "the", "row", + "--learned=not", "yet" + }); + assertEquals("The failing scenario", parsed.name()); + assertEquals("run-42", parsed.coordination().runId()); + assertEquals("agent-7", parsed.coordination().agentId()); + assertEquals("wave", parsed.coordination().group()); + assertEquals(4, parsed.coordination().sequence()); + assertEquals("Dan the author", parsed.coordination().who()); + assertEquals("check the row", parsed.coordination().why()); + assertEquals("not yet", parsed.coordination().learned()); + String joined = String.join("\n", parsed.forwarded()); + assertFalse(joined.contains("--run-id")); + assertFalse(joined.contains("run-42")); + assertFalse(joined.contains("--agent")); + assertFalse(joined.contains("--who")); + assertFalse(joined.contains("--why")); + assertFalse(joined.contains("--learned")); + assertFalse(joined.contains("--group")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("short-log")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("note")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("inbox")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("finish")); + } + @Test void resolveRunVarsIsAnAgentCoreCommand() { WorkbenchCommandLine.Parsed parsed = WorkbenchCommandLine.parse( diff --git a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java index 28616c10c..b141f567c 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java @@ -2,9 +2,11 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.io.TempDir; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.control.protocol.PickleballLocalLayout; import java.io.ByteArrayOutputStream; @@ -30,6 +32,11 @@ class WorkbenchSessionCommandsTest { @TempDir Path project; + @AfterEach + void clearCoordination() { + AgentCoordination.clearCurrent(); + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void isolateDoesNotBlockOnStdinWhenSessionAlreadyHealthy() throws Exception { @@ -37,13 +44,15 @@ void isolateDoesNotBlockOnStdinWhenSessionAlreadyHealthy() throws Exception { AtomicBoolean started = new AtomicBoolean(); Output output = run( new String[]{"isolate", project.toString()}, - (proj, tags, name, example, log) -> { + launch -> { started.set(true); throw new AssertionError("should not start a second session"); } ); assertEquals(0, output.exitCode); assertTrue(output.stdout.contains("ACK SESSION already-running")); + assertTrue(output.stdout.contains("Only one agent drives an open Workbench window")); + assertTrue(output.stdout.contains("stays headless")); assertFalse(started.get()); } } @@ -56,9 +65,9 @@ void sessionStartDetachesAndDoesNotWaitForTheControllerProcess() throws Exceptio try { Output output = run( new String[]{"session-start", project.toString(), "--example=1", "2", "5"}, - (proj, tags, name, example, log) -> { - assertEquals("1 2 5", example); - FakeSession session = FakeSession.start(proj); + launch -> { + assertEquals("1 2 5", launch.example()); + FakeSession session = FakeSession.start(launch.project(), "cli-session", launch.sessionFile()); started.set(session); Process process = startLongLivedChild(); child.set(process); @@ -85,7 +94,7 @@ void watchedWindowIsPreferredAndStopDoesNotKillIt() throws Exception { ui.onCommand = body -> hit.set(body); Output play = run( new String[]{"play", project.toString(), "--ack-only"}, - (proj, tags, name, example, log) -> { + launch -> { throw new AssertionError("play must not start a session"); } ); @@ -98,7 +107,7 @@ void watchedWindowIsPreferredAndStopDoesNotKillIt() throws Exception { ui.setPid(child.pid()); Output stop = run( new String[]{"stop", project.toString()}, - (proj, tags, name, example, log) -> { + launch -> { throw new AssertionError("stop must not start a session"); } ); @@ -121,7 +130,7 @@ void openScenarioPostsTheSharedCommandAndDoesNotStartASession() throws Exception "open-scenario", project.toString(), "--feature=shop.feature", "--name=Buy", "--example=2.2", "--ack-only" }, - (proj, tags, name, example, log) -> { + launch -> { throw new AssertionError("open-scenario must not start a session"); } ); @@ -162,7 +171,7 @@ void eachSessionCommandPostsToTheOpenSessionAndDoesNotStartOne() throws Exceptio args.add("--ack-only"); Output output = run( args.toArray(String[]::new), - (proj, tags, name, example, log) -> { + launch -> { throw new AssertionError(command[0] + " must not start a session"); } ); @@ -179,7 +188,7 @@ void executeStepAckOnlyExitsWithoutWaitingForDone() throws Exception { try (FakeSession ignored = FakeSession.start(project)) { Output output = run( new String[]{"execute-step", project.toString(), "--text=Given stay", "--ack-only"}, - (proj, tags, name, example, log) -> { + launch -> { throw new AssertionError("execute-step must not start a session process"); } ); @@ -189,6 +198,58 @@ void executeStepAckOnlyExitsWithoutWaitingForDone() throws Exception { } } + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void twoHeadlessIsolatesDoNotShareASessionFile() throws Exception { + List files = new ArrayList<>(); + List sessions = new ArrayList<>(); + List children = new ArrayList<>(); + try { + for (int index = 0; index < 2; index++) { + Output output = run( + new String[]{"isolate", project.toString(), "--run-id=run-" + index, "--agent=agent-" + index}, + launch -> { + files.add(launch.sessionFile()); + assertTrue(launch.sessionFile().toString().contains("run-" + (files.size() - 1))); + FakeSession session = FakeSession.start( + launch.project(), "cli-session", launch.sessionFile() + ); + sessions.add(session); + Process process = startLongLivedChild(); + children.add(process); + return process; + } + ); + assertEquals(0, output.exitCode, output.stderr + output.stdout); + assertTrue(output.stdout.contains("ACK SESSION pid=")); + assertFalse(output.stdout.contains("already-running")); + } + assertEquals(2, files.size()); + assertFalse(files.get(0).equals(files.get(1))); + } finally { + for (Process process : children) process.destroyForcibly(); + for (FakeSession session : sessions) session.close(); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void runIdDoesNotFallThroughToAnOpenWindow() throws Exception { + try (FakeSession ignored = FakeSession.start(project)) { + AtomicBoolean started = new AtomicBoolean(); + Output output = run( + new String[]{"play", project.toString(), "--run-id=run-private", "--ack-only"}, + launch -> { + started.set(true); + throw new AssertionError("a named run must not start or drive the open window"); + } + ); + assertEquals(1, output.exitCode, output.stderr + output.stdout); + assertFalse(started.get()); + assertTrue(output.stderr.contains("No healthy Workbench session")); + } + } + private static Output run(String[] args, WorkbenchSessionCommands.DetachedStarter starter) { ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); From 51b07092f47e246bd675acd579ee841bd4e62e17 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 18:30:19 +0000 Subject: [PATCH 07/23] Bottom line: agent guides say to read the short log, keep a private run id, and let only one agent drive an open window. Exported guidance matches the docs. The short log is what to read before a run, after a run, and during a long session, instead of the dense diagnostic log. --- AGENTS.md | 4 +-- docs/agent/README.md | 2 +- docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 2 ++ docs/ai-run-configuration.md | 2 ++ docs/configuration.md | 26 ++++++++++++++++++- docs/consumer-agent-guide.md | 8 +++++- docs/consumer-project.md | 4 +-- docs/pickleball-workbench.md | 7 ++++- .../.github/copilot-instructions.md | 2 ++ maven-consumer-project/AGENTS.md | 2 ++ pickleball-workbench/AGENTS.md | 2 +- .../pickleball/guidance/AGENT-GUIDE.md | 8 +++++- .../pickleball/guidance/docs/agent/README.md | 2 +- .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 2 ++ .../guidance/docs/ai-run-configuration.md | 2 ++ .../pickleball/guidance/docs/configuration.md | 26 ++++++++++++++++++- .../guidance/docs/consumer-agent-guide.md | 8 +++++- .../guidance/docs/consumer-project.md | 4 +-- .../guidance/docs/pickleball-workbench.md | 7 ++++- .../WorkbenchPointerContractTest.java | 4 +++ 22 files changed, 110 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29f7ef8be..a7eff2669 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ When an investigation requires a rerun, start from the selected run's retained ` A partial controlled input inherits only missing project execution-context RunVars: `pkb_glue`, `pkb_features`, `pkb_datapath`, `pkb_callpath`, `pkb_componentpath`, and `pkb_configpath`. An explicit blank value suppresses inheritance and remains a blank tombstone in the canonical `pkb_run_profile`, preserving the existing subsystem fallback semantics when replayed. Do not reconstruct optional RunVars by manually combining runner defaults, property files, named profiles, system properties, and Cucumber aliases when a retained final run profile is available. -Diagnostic lineage is metadata, not part of the execution RunVars. Supply `pkb_investigation_id`, `pkb_run_purpose`, `pkb_parent_run_id`, `pkb_baseline_run_id`, and `pkb_changed_variables` separately from `pkb_runvars`; profiles must not contain those keys. After the rerun, verify `runProfileFingerprint` / compatibility field `directRunProfile` and the declared changed variables before attributing observed differences to the intended configuration change. Use `runProfileFingerprint`, not `configurationHash`, as the equality check for the final RunVar set. See `docs/ai-run-configuration.md`. +Diagnostic lineage is metadata, not part of the execution RunVars. Supply `pkb_investigation_id`, `pkb_run_purpose`, `pkb_parent_run_id`, `pkb_baseline_run_id`, `pkb_changed_variables`, `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` separately from `pkb_runvars`; profiles must not contain those keys. After the rerun, verify `runProfileFingerprint` / compatibility field `directRunProfile` and the declared changed variables before attributing observed differences to the intended configuration change. Use `runProfileFingerprint`, not `configurationHash`, as the equality check for the final RunVar set. See `docs/ai-run-configuration.md`. ## Sources of truth @@ -183,7 +183,7 @@ Only the separate consumer worker JVM executes Pickleball. `WorkbenchWorkerManag The published Pickleball JAR embeds the completed controller-only Workbench JAR as exactly one opaque payload at `META-INF/pickleball/workbench/pickleball-workbench.jar`. It must not flatten Workbench/MCP classes into the outer runtime, and the nested Workbench must not contain core. The ownership mnemonic is: **Pickleball may contain Workbench; Workbench must not contain Pickleball.** -The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not start the GUI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. +The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not start the GUI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. ## Build and validation diff --git a/docs/agent/README.md b/docs/agent/README.md index c7571bd82..1ca179528 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -18,7 +18,7 @@ This directory supports repository-native AI coding agents. It is not a runtime Agent adapters should remain small and point back to the canonical contract rather than copying the full project description. -The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. +The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window; other agents stay headless. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. The nested `/maven-consumer-project/.github/copilot-instructions.md` is identical to `AGENTS.md` so Copilot Chat sees the same Workbench pointer. diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index bd1e27c30..984dcf826 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 82d488d93..ed054b968 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -151,6 +151,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/control/ControlRuntime.java` - `src/main/java/tools/dscode/common/control/ControlRuntimeAspect.java` - `src/main/java/tools/dscode/common/control/ControlValueEvent.java` +- `src/main/java/tools/dscode/common/coordination/AgentCoordination.java` - `src/main/java/tools/dscode/common/CoreSteps.java` - `src/main/java/tools/dscode/common/dataelements/CollectionDataAdapter.java` - `src/main/java/tools/dscode/common/dataelements/CollectionQueryEngine.java` @@ -403,6 +404,7 @@ This inventory helps coding agents discover relevant files. It does not replace ## Framework tests - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` +- `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index 747683a68..a0e568a6f 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -432,6 +432,8 @@ When operating in a consumer project: The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not start the GUI. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. + ```text pkb_browser= pkb_parallel= diff --git a/docs/configuration.md b/docs/configuration.md index 1dd6b68d6..d76fcac19 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,9 +96,33 @@ pkb_run_purpose pkb_parent_run_id pkb_baseline_run_id pkb_changed_variables +pkb_run_id +pkb_agent_id +pkb_run_group +pkb_run_sequence +pkb_run_who +pkb_run_why ``` -Lineage survives controlled execution but is excluded from `pkb_run_profile` and `runProfileFingerprint`. +Lineage and coordination metadata survive controlled execution but are excluded from `pkb_run_profile` and `runProfileFingerprint`. Do not put `pkb_run_id`, the run directory, `pkb_diagnostic_output`, or a browser profile path inside `pkb_runvars`. A confirm replay must not reuse another run's directory or browser profile. + +## Agent coordination + +Different consumer agents can run different tests at the same time. The shared files under the consumer `.pickleball` directory are a bulletin board. Run data stays private to each run. + +Every discover, confirm, isolate, and normal consumer run gets a unique run id when the caller does not pass one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. + +Reports, diagnostic packs, scratch, the local browser profile, and a headless Workbench session file for that run live under `.pickleball/runs//`. Two runs never write the same output folder, the same browser profile, or the same live session file. + +The run record is `.pickleball/runs//record.json`: run id, agent id, optional group, sequence, who, why, what was learned, start time, stop time, status, and the path to the run data. The short log only points at that record. + +The short log is `.pickleball/agent-log`. Read it before a run, after a run, and during a long session, instead of the dense diagnostic log. Each line is a timestamp, agent id, run id, `start` or `stop` or `note`, a one-line purpose, and the path to the run record. Appending does not take a file lock. A dead agent must not block the others. On each write, lines older than 3 days are dropped. Newer lines stay. + +`inbox//` is a note for that agent. `inbox/any/` is a note any active agent may take. A note is one short file: who wrote it, timestamp, one message, optional path to a run record. The reader deletes the note after taking it. The inbox is not a second log and not an assignment framework. + +When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. + +Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. ## Named profiles diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index ab11c6786..bb253dfc3 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -18,7 +18,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. 1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. 2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. @@ -29,6 +29,12 @@ Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, ` Do not copy consumer features into `.pickleball` as a sandbox. +### Run coordination + +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. + +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. Reports, diagnostic packs, scratch, the browser profile, and a headless session file for that run stay under that directory. Two runs do not share an output folder, a browser profile, or a live session. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. + ### Live isolation loop After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. diff --git a/docs/consumer-project.md b/docs/consumer-project.md index 70183a777..01b34b64d 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot @@ -159,7 +159,7 @@ Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a ./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11'" ``` -Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. +Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window. Other agents stay headless on their own run ids. After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 0d9b9354d..3ea82a910 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -68,9 +68,14 @@ Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `g "-Dexec.args=confirm --tags=@smoke --example='1 2 5 3.4 7-11'" "-Dexec.args=isolate --example=1.1" "-Dexec.args=execute-step --text='Given stay'" +"-Dexec.args=short-log" +"-Dexec.args=note --text='row 2 failed closed'" +"-Dexec.args=inbox --write --to=any --text='window is taken'" +"-Dexec.args=inbox --take --agent=agent-1" +"-Dexec.args=finish --run-id= --learned='the empty row fails'" ``` -Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. +Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. `hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not start the GUI. diff --git a/maven-consumer-project/.github/copilot-instructions.md b/maven-consumer-project/.github/copilot-instructions.md index 48591b8e7..511652b42 100644 --- a/maven-consumer-project/.github/copilot-instructions.md +++ b/maven-consumer-project/.github/copilot-instructions.md @@ -4,4 +4,6 @@ Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `- When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. + Gradle projects use `gradlew.bat` or `gradlew` the same way. Pick the script from the OS. diff --git a/maven-consumer-project/AGENTS.md b/maven-consumer-project/AGENTS.md index 48591b8e7..511652b42 100644 --- a/maven-consumer-project/AGENTS.md +++ b/maven-consumer-project/AGENTS.md @@ -4,4 +4,6 @@ Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `- When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. + Gradle projects use `gradlew.bat` or `gradlew` the same way. Pick the script from the OS. diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index 2fc96d05e..da7435eec 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -109,7 +109,7 @@ Workbench provides: java -cp pickleball-workbench-.jar: tools.dscode.workbench.WorkbenchApplication mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the front door. When a Workbench window is already open, drive that session with the same commands the window calls so the person sees the result. When nobody is watching, launcher `isolate` / `execute-step` stay on the headless CLI session. Hosts may already wire `mcp .` as optional alias. Do not start the GUI and do not click the JavaFX or WebView UI. Do not embed Maven or Gradle in MCP or the launcher. If a tool needs a build, it invokes the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not add a general `mvn` or `gradle` tool. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the front door. When a Workbench window is already open, drive that session with the same commands the window calls so the person sees the result. When nobody is watching, launcher `isolate` / `execute-step` stay on the headless CLI session. Hosts may already wire `mcp .` as optional alias. Do not start the GUI and do not click the JavaFX or WebView UI. Do not embed Maven or Gradle in MCP or the launcher. If a tool needs a build, it invokes the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Workbench does not own that board; the consumer launcher does. A per-run `--session-file` is only a path plus `--run-id` / `--agent-id` strings copied onto the worker. UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index ab11c6786..bb253dfc3 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -18,7 +18,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. 1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. 2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. @@ -29,6 +29,12 @@ Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, ` Do not copy consumer features into `.pickleball` as a sandbox. +### Run coordination + +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. + +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. Reports, diagnostic packs, scratch, the browser profile, and a headless session file for that run stay under that directory. Two runs do not share an output folder, a browser profile, or a live session. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. + ### Live isolation loop After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md index c7571bd82..1ca179528 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md @@ -18,7 +18,7 @@ This directory supports repository-native AI coding agents. It is not a runtime Agent adapters should remain small and point back to the canonical contract rather than copying the full project description. -The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. +The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window; other agents stay headless. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. The nested `/maven-consumer-project/.github/copilot-instructions.md` is identical to `AGENTS.md` so Copilot Chat sees the same Workbench pointer. 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 bd1e27c30..984dcf826 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 @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | 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 82d488d93..ed054b968 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 @@ -151,6 +151,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/control/ControlRuntime.java` - `src/main/java/tools/dscode/common/control/ControlRuntimeAspect.java` - `src/main/java/tools/dscode/common/control/ControlValueEvent.java` +- `src/main/java/tools/dscode/common/coordination/AgentCoordination.java` - `src/main/java/tools/dscode/common/CoreSteps.java` - `src/main/java/tools/dscode/common/dataelements/CollectionDataAdapter.java` - `src/main/java/tools/dscode/common/dataelements/CollectionQueryEngine.java` @@ -403,6 +404,7 @@ This inventory helps coding agents discover relevant files. It does not replace ## Framework tests - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` +- `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index 747683a68..a0e568a6f 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -432,6 +432,8 @@ When operating in a consumer project: The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not start the GUI. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. + ```text pkb_browser= pkb_parallel= diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index 1dd6b68d6..d76fcac19 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -96,9 +96,33 @@ pkb_run_purpose pkb_parent_run_id pkb_baseline_run_id pkb_changed_variables +pkb_run_id +pkb_agent_id +pkb_run_group +pkb_run_sequence +pkb_run_who +pkb_run_why ``` -Lineage survives controlled execution but is excluded from `pkb_run_profile` and `runProfileFingerprint`. +Lineage and coordination metadata survive controlled execution but are excluded from `pkb_run_profile` and `runProfileFingerprint`. Do not put `pkb_run_id`, the run directory, `pkb_diagnostic_output`, or a browser profile path inside `pkb_runvars`. A confirm replay must not reuse another run's directory or browser profile. + +## Agent coordination + +Different consumer agents can run different tests at the same time. The shared files under the consumer `.pickleball` directory are a bulletin board. Run data stays private to each run. + +Every discover, confirm, isolate, and normal consumer run gets a unique run id when the caller does not pass one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. + +Reports, diagnostic packs, scratch, the local browser profile, and a headless Workbench session file for that run live under `.pickleball/runs//`. Two runs never write the same output folder, the same browser profile, or the same live session file. + +The run record is `.pickleball/runs//record.json`: run id, agent id, optional group, sequence, who, why, what was learned, start time, stop time, status, and the path to the run data. The short log only points at that record. + +The short log is `.pickleball/agent-log`. Read it before a run, after a run, and during a long session, instead of the dense diagnostic log. Each line is a timestamp, agent id, run id, `start` or `stop` or `note`, a one-line purpose, and the path to the run record. Appending does not take a file lock. A dead agent must not block the others. On each write, lines older than 3 days are dropped. Newer lines stay. + +`inbox//` is a note for that agent. `inbox/any/` is a note any active agent may take. A note is one short file: who wrote it, timestamp, one message, optional path to a run record. The reader deletes the note after taking it. The inbox is not a second log and not an assignment framework. + +When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. + +Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. ## Named profiles diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index ab11c6786..bb253dfc3 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -18,7 +18,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. 1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. 2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. @@ -29,6 +29,12 @@ Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `isolate`, ` Do not copy consumer features into `.pickleball` as a sandbox. +### Run coordination + +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. + +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. Reports, diagnostic packs, scratch, the browser profile, and a headless session file for that run stay under that directory. Two runs do not share an output folder, a browser profile, or a live session. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. + ### Live isolation loop After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index 70183a777..01b34b64d 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot @@ -159,7 +159,7 @@ Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a ./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11'" ``` -Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. +Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window. Other agents stay headless on their own run ids. After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. 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 0d9b9354d..3ea82a910 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 @@ -68,9 +68,14 @@ Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `g "-Dexec.args=confirm --tags=@smoke --example='1 2 5 3.4 7-11'" "-Dexec.args=isolate --example=1.1" "-Dexec.args=execute-step --text='Given stay'" +"-Dexec.args=short-log" +"-Dexec.args=note --text='row 2 failed closed'" +"-Dexec.args=inbox --write --to=any --text='window is taken'" +"-Dexec.args=inbox --take --agent=agent-1" +"-Dexec.args=finish --run-id= --learned='the empty row fails'" ``` -Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. +Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. `hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not start the GUI. diff --git a/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java b/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java index 6752b0347..a06039995 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java @@ -69,6 +69,10 @@ void agentGuidesDriveAnOpenWindowAndUseTheProjectWrapper() throws Exception { assertTrue(lowered.contains("mvnw"), guide.toString()); assertTrue(lowered.contains("machine-wide"), guide.toString()); assertTrue(lowered.contains("already open"), guide.toString()); + assertTrue(lowered.contains("only one agent"), guide.toString()); + assertTrue(lowered.contains("own run id"), guide.toString()); + assertTrue(lowered.contains("agent-log"), guide.toString()); + assertTrue(lowered.contains("short log"), guide.toString()); assertFalse(lowered.contains("only controls"), guide.toString()); } String chooserSource = Files.readString(Path.of("docs/consumer-agent-guide.md")); From 8991cbe8e3c334aed28bbacdee135a67a3f777fc Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 22:47:40 +0000 Subject: [PATCH 08/23] Bottom line: one window reads the run directory it is shown, and only one agent is the driver. RunView loads a run's record, logs, reports, and config without copying them or starting a test. WindowDriver records the live run, the viewed run, and the single driver outside any run directory. --- .../dscode/control/protocol/RunView.java | 138 +++++++++++++ .../dscode/control/protocol/WindowDriver.java | 190 ++++++++++++++++++ .../control/protocol/RunWindowTest.java | 125 ++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java create mode 100644 pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java new file mode 100644 index 000000000..d5ac0cf0e --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java @@ -0,0 +1,138 @@ +package tools.dscode.control.protocol; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Read-only view of one consumer run under {@code .pickleball/runs//}. + * The Workbench window and the launcher session commands both use this class. + * It does not copy the run and it does not start a test. + */ +public final class RunView { + public static final String RUNS_DIRECTORY = "runs"; + public static final String RECORD_FILE = "record.json"; + + private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,119}"); + + private RunView() { + } + + public record Loaded( + String runId, + Path directory, + Path recordFile, + String recordText, + List logs, + List reports, + List config + ) { + } + + public static Path runsRoot(Path project) { + return PickleballLocalLayout.root(project).resolve(RUNS_DIRECTORY); + } + + public static Path runDirectory(Path project, String runId) { + return runsRoot(project).resolve(requireSafeId(runId)); + } + + public static List runIds(Path project) { + Path root = runsRoot(project); + if (!Files.isDirectory(root)) return List.of(); + List ids = new ArrayList<>(); + try (Stream children = Files.list(root)) { + children.filter(Files::isDirectory).forEach(dir -> { + String name = dir.getFileName().toString(); + if (!SAFE_ID.matcher(name).matches() || name.contains("..")) return; + if (Files.isRegularFile(dir.resolve(RECORD_FILE))) ids.add(name); + }); + } catch (IOException ignored) { + return List.of(); + } + ids.sort(Comparator.naturalOrder()); + return List.copyOf(ids); + } + + public static Loaded load(Path project, String runId) { + String safe = requireSafeId(runId); + Path directory = runsRoot(project).resolve(safe); + Path recordFile = directory.resolve(RECORD_FILE); + if (!Files.isRegularFile(recordFile)) { + throw new IllegalArgumentException("No run record for " + safe + " at " + recordFile); + } + String recordText; + try { + recordText = Files.readString(recordFile, StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new IllegalStateException("Could not read run record " + recordFile + ": " + failure.getMessage(), failure); + } + List logs = new ArrayList<>(); + logs.addAll(logFiles(directory)); + logs.addAll(regularFiles(directory.resolve("session")).stream() + .filter(path -> path.getFileName().toString().endsWith(".log")) + .toList()); + logs.sort(Comparator.naturalOrder()); + return new Loaded( + safe, + directory.toAbsolutePath().normalize(), + recordFile.toAbsolutePath().normalize(), + recordText, + List.copyOf(logs), + reports(directory), + regularFiles(directory.resolve("config")) + ); + } + + public static String requireSafeId(String runId) { + if (runId == null || runId.isBlank()) { + throw new IllegalArgumentException("run id is required."); + } + String value = runId.trim(); + if (!SAFE_ID.matcher(value).matches() || value.contains("..")) { + throw new IllegalArgumentException( + "run id must be a single folder name (letters, digits, '.', '_' or '-'): " + runId + ); + } + return value; + } + + private static List logFiles(Path directory) { + if (!Files.isDirectory(directory)) return List.of(); + try (Stream children = Files.list(directory)) { + return children.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".log")) + .map(path -> path.toAbsolutePath().normalize()) + .sorted() + .toList(); + } catch (IOException ignored) { + return List.of(); + } + } + + private static List reports(Path directory) { + List files = new ArrayList<>(); + files.addAll(regularFiles(directory.resolve("reports"))); + files.addAll(regularFiles(directory.resolve("diagnostic"))); + files.sort(Comparator.naturalOrder()); + return List.copyOf(files); + } + + private static List regularFiles(Path directory) { + if (!Files.isDirectory(directory)) return List.of(); + try (Stream walk = Files.walk(directory)) { + return walk.filter(Files::isRegularFile) + .map(path -> path.toAbsolutePath().normalize()) + .sorted() + .toList(); + } catch (IOException ignored) { + return List.of(); + } + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java new file mode 100644 index 000000000..fcb1f1e72 --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java @@ -0,0 +1,190 @@ +package tools.dscode.control.protocol; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Which run the one Workbench window is showing, and which single agent may + * drive that live session. The file sits beside other Workbench state, not + * inside a run directory. Closing the window clears the driver and does not + * delete run data or the short log. + */ +public final class WindowDriver { + public static final String FILE = "window-driver.json"; + + private static final Pattern OPEN = Pattern.compile("\"open\"\\s*:\\s*(true|false)"); + private static final Pattern LIVE = fieldPattern("liveRunId"); + private static final Pattern VIEWED = fieldPattern("viewedRunId"); + private static final Pattern DRIVER = fieldPattern("driverAgent"); + + private WindowDriver() { + } + + public record State(boolean open, String liveRunId, String viewedRunId, String driverAgent) { + public static State closed() { + return new State(false, null, null, null); + } + } + + public record Decision(RunView.Loaded loaded, State state, boolean driving, boolean readOnly) { + } + + public static Path file(Path project) { + return PickleballLocalLayout.workbenchStateRoot(project).resolve(FILE); + } + + public static State read(Path project) { + Path path = file(project); + if (!Files.isRegularFile(path)) return State.closed(); + try { + return parse(Files.readString(path, StandardCharsets.UTF_8)); + } catch (IOException ignored) { + return State.closed(); + } + } + + /** + * Point the one window at {@code runId}. Reads that run's directory and + * does not start a test. A second agent can view another run but does not + * become a second driver of the live session. + */ + public static Decision show(Path project, String runId, String agentId, boolean claim) { + RunView.Loaded loaded = RunView.load(project, runId); + State before = read(project); + String live = before.liveRunId(); + String driver = before.driverAgent(); + String agent = blankToNull(agentId); + boolean driving = false; + boolean readOnly; + if (!before.open()) { + live = loaded.runId(); + if (claim && agent != null) { + driver = agent; + driving = true; + readOnly = false; + } else { + driver = null; + driving = false; + readOnly = false; + } + } else if (claim && agent != null) { + if (driver == null || driver.equals(agent)) { + if (live == null || live.equals(loaded.runId())) { + if (live == null) live = loaded.runId(); + driver = agent; + driving = true; + readOnly = false; + } else { + driving = false; + readOnly = true; + } + } else { + driving = false; + readOnly = true; + } + } else { + readOnly = live != null && !live.equals(loaded.runId()); + } + State next = new State(true, live, loaded.runId(), driver); + write(project, next); + return new Decision(loaded, next, driving, readOnly); + } + + public static State markOpen(Path project) { + State before = read(project); + State next = new State(true, before.liveRunId(), before.viewedRunId(), before.driverAgent()); + write(project, next); + return next; + } + + /** + * The window is gone. The live run id is remembered only so a later open + * can see it; the driver is cleared. Run directories and the short log + * are not touched. + */ + public static void closeWindow(Path project) { + State before = read(project); + write(project, new State(false, before.liveRunId(), before.viewedRunId(), null)); + } + + public static Optional refuseSecondDriver(Path project, String agentId) { + String agent = blankToNull(agentId); + if (agent == null) return Optional.empty(); + State state = read(project); + if (!state.open() || state.driverAgent() == null || state.driverAgent().equals(agent)) { + return Optional.empty(); + } + String live = state.liveRunId() == null ? "the open window" : state.liveRunId(); + return Optional.of( + "A second agent does not become a second driver of the live session " + live + "." + ); + } + + public static void claimVacant(Path project, String agentId) { + String agent = blankToNull(agentId); + if (agent == null) return; + State state = read(project); + if (!state.open() || state.driverAgent() != null) return; + write(project, new State(true, state.liveRunId(), state.viewedRunId(), agent)); + } + + static State parse(String json) { + if (json == null || json.isBlank()) return State.closed(); + Matcher open = OPEN.matcher(json); + boolean shown = open.find() && Boolean.parseBoolean(open.group(1)); + return new State(shown, text(LIVE, json), text(VIEWED, json), text(DRIVER, json)); + } + + private static void write(Path project, State state) { + Path target = file(project); + try { + Files.createDirectories(target.getParent()); + Path temp = target.resolveSibling(target.getFileName() + ".tmp-" + UUID.randomUUID()); + Files.writeString(temp, toJson(state), StandardCharsets.UTF_8); + try { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException failure) { + throw new IllegalStateException("Could not record the Workbench window driver: " + failure.getMessage(), failure); + } + } + + private static String toJson(State state) { + return "{\n" + + " \"open\": " + state.open() + ",\n" + + " \"liveRunId\": " + jsonString(state.liveRunId()) + ",\n" + + " \"viewedRunId\": " + jsonString(state.viewedRunId()) + ",\n" + + " \"driverAgent\": " + jsonString(state.driverAgent()) + "\n" + + "}\n"; + } + + private static String jsonString(String value) { + if (value == null) return "null"; + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private static Pattern fieldPattern(String name) { + return Pattern.compile("\"" + name + "\"\\s*:\\s*(null|\"([^\"]*)\")"); + } + + private static String text(Pattern pattern, String json) { + Matcher matcher = pattern.matcher(json); + if (!matcher.find() || "null".equals(matcher.group(1))) return null; + String value = matcher.group(2); + return value == null || value.isBlank() ? null : value; + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java b/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java new file mode 100644 index 000000000..2176373d6 --- /dev/null +++ b/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java @@ -0,0 +1,125 @@ +package tools.dscode.control.protocol; + +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.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RunWindowTest { + @TempDir + Path project; + + @Test + void openingARunLoadsItsRecordAndDoesNotStartATest() throws Exception { + Path run = writeRun("alpha", "ALPHA-RECORD", "alpha.log", "alpha-report.txt", "alpha.properties"); + Map before = snapshot(run); + + WindowDriver.Decision decision = WindowDriver.show(project, "alpha", "agent-1", true); + + assertEquals("alpha", decision.loaded().runId()); + assertTrue(decision.loaded().recordText().contains("ALPHA-RECORD")); + assertTrue(decision.loaded().logs().stream().anyMatch(path -> path.toString().contains("alpha.log"))); + assertTrue(decision.loaded().reports().stream().anyMatch(path -> path.toString().contains("alpha-report.txt"))); + assertTrue(decision.loaded().config().stream().anyMatch(path -> path.toString().contains("alpha.properties"))); + assertTrue(decision.driving()); + assertFalse(decision.readOnly()); + assertEquals("agent-1", decision.state().driverAgent()); + assertEquals("alpha", decision.state().liveRunId()); + assertEquals(before, snapshot(run)); + assertFalse(Files.exists(run.resolve("session").resolve("cli-session.json"))); + } + + @Test + void switchingRunsShowsTheOtherFilesAndDoesNotWriteTheFirstRun() throws Exception { + Path first = writeRun("alpha", "ALPHA-RECORD", "alpha.log", "alpha-report.txt", "alpha.properties"); + Path second = writeRun("beta", "BETA-RECORD", "beta.log", "beta-report.txt", "beta.properties"); + WindowDriver.show(project, "alpha", "agent-1", true); + Map before = snapshot(first); + + WindowDriver.Decision decision = WindowDriver.show(project, "beta", null, false); + + assertEquals("beta", decision.loaded().runId()); + assertTrue(decision.loaded().recordText().contains("BETA-RECORD")); + assertTrue(decision.loaded().logs().stream().anyMatch(path -> path.getFileName().toString().equals("beta.log"))); + assertTrue(decision.loaded().reports().stream().anyMatch(path -> path.getFileName().toString().equals("beta-report.txt"))); + assertTrue(decision.loaded().config().stream().anyMatch(path -> path.getFileName().toString().equals("beta.properties"))); + assertEquals("alpha", decision.state().liveRunId()); + assertEquals("beta", decision.state().viewedRunId()); + assertTrue(decision.readOnly()); + assertFalse(decision.driving()); + assertEquals(before, snapshot(first)); + assertTrue(Files.isRegularFile(second.resolve("record.json"))); + } + + @Test + void closingTheWindowLeavesTheRunDirectoryAndTheShortLog() throws Exception { + Path run = writeRun("alpha", "ALPHA-RECORD", "alpha.log", "alpha-report.txt", "alpha.properties"); + Path log = PickleballLocalLayout.root(project).resolve("agent-log"); + Files.createDirectories(log.getParent()); + Files.writeString(log, "keep-this-short-log\n"); + WindowDriver.show(project, "alpha", "agent-1", true); + Map runBefore = snapshot(run); + byte[] logBefore = Files.readAllBytes(log); + + WindowDriver.closeWindow(project); + + assertEquals(runBefore, snapshot(run)); + assertEquals(new String(logBefore), Files.readString(log)); + assertFalse(WindowDriver.read(project).open()); + assertTrue(WindowDriver.read(project).driverAgent() == null); + } + + @Test + void aSecondAgentDoesNotBecomeASecondDriver() { + writeRun("alpha", "ALPHA-RECORD", "alpha.log", "alpha-report.txt", "alpha.properties"); + writeRun("beta", "BETA-RECORD", "beta.log", "beta-report.txt", "beta.properties"); + WindowDriver.show(project, "alpha", "agent-1", true); + + WindowDriver.Decision viewed = WindowDriver.show(project, "beta", "agent-2", true); + + assertFalse(viewed.driving()); + assertTrue(viewed.readOnly()); + assertEquals("agent-1", viewed.state().driverAgent()); + assertEquals("alpha", viewed.state().liveRunId()); + assertEquals("beta", viewed.state().viewedRunId()); + assertTrue(WindowDriver.refuseSecondDriver(project, "agent-2").isPresent()); + assertTrue(WindowDriver.refuseSecondDriver(project, "agent-2").get() + .contains("does not become a second driver")); + assertTrue(WindowDriver.refuseSecondDriver(project, "agent-1").isEmpty()); + } + + private Path writeRun(String id, String record, String logName, String reportName, String configName) { + try { + Path dir = PickleballLocalLayout.root(project).resolve("runs").resolve(id); + Files.createDirectories(dir.resolve("session")); + Files.createDirectories(dir.resolve("reports")); + Files.createDirectories(dir.resolve("config")); + Files.writeString(dir.resolve("record.json"), "{\"runId\":\"" + id + "\",\"note\":\"" + record + "\"}\n"); + Files.writeString(dir.resolve("session").resolve(logName), record + "-log\n"); + Files.writeString(dir.resolve("reports").resolve(reportName), record + "-report\n"); + Files.writeString(dir.resolve("config").resolve(configName), record + "-config\n"); + return dir; + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + + private static Map snapshot(Path root) throws Exception { + Map files = new LinkedHashMap<>(); + if (!Files.exists(root)) return files; + try (var walk = Files.walk(root)) { + for (Path path : walk.filter(Files::isRegularFile).sorted().toList()) { + files.put(root.relativize(path).toString(), HexFormat.of().formatHex(Files.readAllBytes(path))); + } + } + return files; + } +} From 858d84e91f66ad3dcc5f7dfdd6714ec2960f7ead Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 22:47:40 +0000 Subject: [PATCH 09/23] Bottom line: open-window and show-run load a run and do not start a test; close-window leaves the run and the short log. A second agent can view another run but does not become a second driver. Commands with a run id still stay on that run's own session. --- .../dscode/launcher/WorkbenchCommandLine.java | 3 +- .../launcher/WorkbenchSessionCommands.java | 218 ++++++++++++++++ .../WorkbenchSessionCommandsTest.java | 240 ++++++++++++++++++ 3 files changed, 460 insertions(+), 1 deletion(-) diff --git a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java index 828780c7c..07ed34955 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java @@ -22,7 +22,8 @@ final class WorkbenchCommandLine { "isolate", "session-start", "execute-step", "status", "events", "stop", "kill", "open-scenario", "example", "play", "from-here", "pause", "insert-step", "update-step", "diagnostic-run", "save", "refresh", - "session-sync", "worker-start", "worker-restart", "worker-stop" + "session-sync", "worker-start", "worker-restart", "worker-stop", + "open-window", "close-window", "show-run" ); private WorkbenchCommandLine() { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java index 4c365336d..78b027866 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchSessionCommands.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.control.protocol.PickleballLocalLayout; +import tools.dscode.control.protocol.RunView; +import tools.dscode.control.protocol.WindowDriver; import java.io.IOException; import java.io.PrintStream; @@ -52,6 +54,11 @@ interface DetachedStarter { Process start(SessionLaunch launch) throws IOException; } + @FunctionalInterface + interface UiStarter { + Process start(Path project, String runId, String agentId) throws IOException; + } + public static int run(String[] args, PrintStream out, PrintStream err) { return run(args, out, err, WorkbenchSessionCommands::startControllerSession, defaultHttp()); } @@ -71,6 +78,27 @@ static int run( PrintStream err, DetachedStarter starter, HttpClient http + ) { + return run(args, out, err, starter, http, WorkbenchSessionCommands::startUi); + } + + static int run( + String[] args, + PrintStream out, + PrintStream err, + DetachedStarter starter, + UiStarter uiStarter + ) { + return run(args, out, err, starter, defaultHttp(), uiStarter); + } + + static int run( + String[] args, + PrintStream out, + PrintStream err, + DetachedStarter starter, + HttpClient http, + UiStarter uiStarter ) { WorkbenchCommandLine.Parsed parsed; try { @@ -94,6 +122,9 @@ static int run( case "status" -> status(parsed, flags, out, err, http); case "events" -> events(parsed, out, err, http); case "stop", "kill" -> stop(parsed, out, err, http); + case "open-window" -> openWindow(parsed, out, err, http, uiStarter); + case "close-window" -> closeWindow(parsed, out, err, http); + case "show-run" -> showRun(parsed, out, err, http); default -> { err.println("Unknown Workbench session command: " + parsed.command()); yield 2; @@ -150,6 +181,179 @@ static Process startDetached(Path project, List command, Path logFile) t return builder.start(); } + static Process startUi(Path project, String runId, String agentId) throws IOException { + Path controllerJar = PickleballWorkbenchLauncher.extractEmbeddedPayload(project); + List forwarded = new ArrayList<>(); + forwarded.add("ui"); + forwarded.add(project.toString()); + if (runId != null && !runId.isBlank()) { + forwarded.add("--run-id"); + forwarded.add(runId); + } + if (agentId != null && !agentId.isBlank()) { + forwarded.add("--agent-id"); + forwarded.add(agentId); + } + Path logFile = PickleballLocalLayout.workbenchStateRoot(project).resolve("window.log"); + return startDetached(project, PickleballWorkbenchLauncher.command( + controllerJar, forwarded.toArray(String[]::new) + ), logFile); + } + + private static int openWindow( + WorkbenchCommandLine.Parsed parsed, + PrintStream out, + PrintStream err, + HttpClient http, + UiStarter uiStarter + ) { + return presentRun(parsed, out, err, http, uiStarter, true); + } + + private static int showRun( + WorkbenchCommandLine.Parsed parsed, + PrintStream out, + PrintStream err, + HttpClient http + ) { + return presentRun(parsed, out, err, http, null, false); + } + + private static int presentRun( + WorkbenchCommandLine.Parsed parsed, + PrintStream out, + PrintStream err, + HttpClient http, + UiStarter uiStarter, + boolean openIfClosed + ) { + String runId = parsed.coordination().runId(); + if (runId == null || runId.isBlank()) { + err.println("Usage: " + parsed.command() + " --run-id="); + return 2; + } + RunView.Loaded loaded; + try { + loaded = RunView.load(parsed.project(), runId); + } catch (RuntimeException failure) { + err.println(failure.getMessage()); + return 1; + } + out.println("run-id=" + loaded.runId()); + out.println("run-record=" + loaded.recordFile()); + out.println(loaded.recordText()); + for (Path log : loaded.logs()) out.println("log=" + log); + for (Path report : loaded.reports()) out.println("report=" + report); + for (Path config : loaded.config()) out.println("config=" + config); + out.println("Opening the window loads the run you already have. It does not start a test."); + + Optional window = openWindow(parsed.project(), http); + boolean pointTheWindow = openIfClosed || window.isPresent(); + WindowDriver.Decision decision = null; + if (pointTheWindow) { + boolean claim = openIfClosed && parsed.coordination().agentId() != null; + decision = WindowDriver.show(parsed.project(), runId, parsed.coordination().agentId(), claim); + out.println("live-run=" + (decision.state().liveRunId() == null ? "" : decision.state().liveRunId())); + out.println("viewed-run=" + loaded.runId()); + if (decision.readOnly() + && parsed.coordination().agentId() != null + && decision.state().driverAgent() != null + && !decision.state().driverAgent().equals(parsed.coordination().agentId())) { + out.println("A second agent does not become a second driver of the live session " + + decision.state().liveRunId() + "."); + } + } + if (window.isEmpty() && openIfClosed) { + if (uiStarter == null) { + err.println("No Workbench window is open. open-window starts it; show-run does not."); + return 1; + } + Path logFile = PickleballLocalLayout.workbenchStateRoot(parsed.project()).resolve("window.log"); + Process process; + try { + process = uiStarter.start(parsed.project(), runId, parsed.coordination().agentId()); + } catch (IOException failure) { + err.println("Could not open the Workbench window: " + failure.getMessage()); + return 1; + } + long deadline = System.nanoTime() + HEALTH_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline && openWindow(parsed.project(), http).isEmpty()) { + if (process != null && !process.isAlive() && openWindow(parsed.project(), http).isEmpty()) { + err.println("Workbench window exited before it could show the run. See " + logFile); + return 1; + } + sleep(POLL); + } + window = openWindow(parsed.project(), http); + if (window.isEmpty()) { + err.println("Workbench window did not become healthy within " + + HEALTH_TIMEOUT.toSeconds() + "s. See " + logFile); + return 1; + } + } + if (window.isPresent()) { + Map body = new LinkedHashMap<>(); + body.put("op", "show-run"); + body.put("runId", loaded.runId()); + if (parsed.coordination().agentId() != null) body.put("agent", parsed.coordination().agentId()); + boolean driving = decision != null && decision.driving(); + body.put("claim", Boolean.toString(openIfClosed && driving)); + try { + postJson(http, window.get(), "/commands", body); + } catch (RuntimeException failure) { + err.println("Workbench window did not show the run: " + failure.getMessage()); + return 1; + } + } + String driver = decision != null && decision.state().driverAgent() != null + ? decision.state().driverAgent() + : ""; + out.println("ACK WINDOW " + (openIfClosed ? "opened" : "shown") + + " run-id=" + loaded.runId() + + (decision != null && decision.driving() ? " driver=" + driver : " read-only")); + return 0; + } + + private static int closeWindow( + WorkbenchCommandLine.Parsed parsed, + PrintStream out, + PrintStream err, + HttpClient http + ) { + WindowDriver.closeWindow(parsed.project()); + Optional window = openWindow(parsed.project(), http); + if (window.isPresent()) { + try { + postJson(http, window.get(), "/commands", Map.of("op", "close-window")); + } catch (RuntimeException failure) { + err.println("Workbench window did not close: " + failure.getMessage()); + return 1; + } + } + out.println("ACK WINDOW closed"); + out.println("The run directory and the short log are unchanged. A headless run keeps going."); + return 0; + } + + private static Optional openWindow(Path project, HttpClient http) { + Optional attached = readHealthyFile(PickleballLocalLayout.attachFile(project), project, http); + if (attached.isPresent() && "ui-attach".equals(attached.get().mode())) return attached; + return Optional.empty(); + } + + private static Integer refusedDriver(WorkbenchCommandLine.Parsed parsed, PrintStream err) { + if (parsed.coordination().runId() != null && !parsed.coordination().runId().isBlank()) return null; + Optional refused = WindowDriver.refuseSecondDriver( + parsed.project(), parsed.coordination().agentId() + ); + if (refused.isPresent()) { + err.println(refused.get()); + return 1; + } + WindowDriver.claimVacant(parsed.project(), parsed.coordination().agentId()); + return null; + } + private static int sessionStart( WorkbenchCommandLine.Parsed parsed, PrintStream out, @@ -228,6 +432,8 @@ private static int executeStep( PrintStream err, HttpClient http ) { + Integer refused = refusedDriver(parsed, err); + if (refused != null) return refused; SessionState state = requireSession(parsed, http); String text = flags.text; if (text == null || text.isBlank()) { @@ -250,6 +456,8 @@ private static int textCommand( err.println("Usage: " + parsed.command() + " --text="); return 2; } + Integer refused = refusedDriver(parsed, err); + if (refused != null) return refused; SessionState state = requireSession(parsed, http); return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); } @@ -267,6 +475,8 @@ private static int openScenario( err.println("Usage: open-scenario --feature= --name= [--example=]"); return 2; } + Integer refused = refusedDriver(parsed, err); + if (refused != null) return refused; SessionState state = requireSession(parsed, http); return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); } @@ -284,6 +494,8 @@ private static int example( err.println("Usage: example --example="); return 2; } + Integer refused = refusedDriver(parsed, err); + if (refused != null) return refused; SessionState state = requireSession(parsed, http); Map body = commandBody(parsed, flags); body.put("example", selector); @@ -312,6 +524,8 @@ private static int postCommand( PrintStream err, HttpClient http ) { + Integer refused = refusedDriver(parsed, err); + if (refused != null) return refused; SessionState state = requireSession(parsed, http); return postAndMaybeWait(state, flags, commandBody(parsed, flags), out, err, http); } @@ -326,6 +540,8 @@ private static Map commandBody(WorkbenchCommandLine.Parsed parse if (parsed.example() != null && !parsed.example().isBlank()) body.put("example", parsed.example()); if (flags.run != null && !flags.run.isBlank()) body.put("run", flags.run); if (flags.fromHere != null && !flags.fromHere.isBlank()) body.put("fromHere", flags.fromHere); + if (parsed.coordination().agentId() != null) body.put("agent", parsed.coordination().agentId()); + if (parsed.coordination().runId() != null) body.put("runId", parsed.coordination().runId()); return body; } @@ -398,6 +614,8 @@ private static int stop( PrintStream err, HttpClient http ) { + Integer refused = refusedDriver(parsed, err); + if (refused != null) return refused; Optional state = resolveSession(parsed, http); if (state.isEmpty()) { out.println("ACK SESSION already-stopped"); diff --git a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java index b141f567c..074eb6198 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java @@ -18,8 +18,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -250,6 +253,243 @@ void runIdDoesNotFallThroughToAnOpenWindow() throws Exception { } } + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void openingAWindowOnARunIdLoadsTheRecordAndDoesNotStartATest() throws Exception { + Path run = writeRun("alpha", "ALPHA-RECORD"); + Map before = snapshot(run); + AtomicBoolean sessionStarted = new AtomicBoolean(); + AtomicReference ui = new AtomicReference<>(); + AtomicReference windowProcess = new AtomicReference<>(); + AtomicReference posted = new AtomicReference<>(); + try { + Output output = run( + new String[]{"open-window", project.toString(), "--run-id=alpha", "--agent=agent-1"}, + launch -> { + sessionStarted.set(true); + throw new AssertionError("open-window must not start a test"); + }, + (root, runId, agentId) -> { + assertEquals("alpha", runId); + assertEquals("agent-1", agentId); + FakeSession session = FakeSession.start( + root, "ui-attach", PickleballLocalLayout.attachFile(root)); + session.onCommand = posted::set; + ui.set(session); + Process process = startLongLivedChild(); + windowProcess.set(process); + return process; + } + ); + assertEquals(0, output.exitCode, output.stderr + output.stdout); + assertTrue(output.stdout.contains("ALPHA-RECORD"), output.stdout); + assertTrue(output.stdout.contains("does not start a test"), output.stdout); + assertTrue(output.stdout.contains("ACK WINDOW opened"), output.stdout); + assertFalse(sessionStarted.get()); + assertFalse(Files.exists(run.resolve("session").resolve("cli-session.json"))); + assertEquals(before, snapshot(run)); + assertTrue(posted.get() != null && posted.get().contains("\"op\":\"show-run\""), String.valueOf(posted.get())); + assertEquals("alpha", tools.dscode.control.protocol.WindowDriver.read(project).liveRunId()); + assertEquals("agent-1", tools.dscode.control.protocol.WindowDriver.read(project).driverAgent()); + } finally { + if (ui.get() != null) ui.get().close(); + if (windowProcess.get() != null) windowProcess.get().destroyForcibly(); + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void switchingRunIdsShowsTheOtherRunAndDoesNotWriteTheFirst() throws Exception { + Path first = writeRun("alpha", "ALPHA-RECORD"); + writeRun("beta", "BETA-RECORD"); + Map before = snapshot(first); + AtomicReference ui = new AtomicReference<>(); + AtomicReference windowProcess = new AtomicReference<>(); + AtomicReference posted = new AtomicReference<>(); + try { + Output opened = run( + new String[]{"open-window", project.toString(), "--run-id=alpha", "--agent=agent-1"}, + launch -> { + throw new AssertionError("open-window must not start a test"); + }, + (root, runId, agentId) -> { + FakeSession session = FakeSession.start( + root, "ui-attach", PickleballLocalLayout.attachFile(root)); + session.onCommand = posted::set; + ui.set(session); + Process process = startLongLivedChild(); + windowProcess.set(process); + return process; + } + ); + assertEquals(0, opened.exitCode, opened.stderr + opened.stdout); + before = snapshot(first); + posted.set(null); + Output shown = run( + new String[]{"show-run", project.toString(), "--run-id=beta", "--agent=agent-1"}, + launch -> { + throw new AssertionError("show-run must not start a test"); + } + ); + assertEquals(0, shown.exitCode, shown.stderr + shown.stdout); + assertTrue(shown.stdout.contains("BETA-RECORD"), shown.stdout); + assertTrue(shown.stdout.contains("beta.log"), shown.stdout); + assertTrue(shown.stdout.contains("does not start a test"), shown.stdout); + assertEquals(before, snapshot(first)); + assertEquals("alpha", tools.dscode.control.protocol.WindowDriver.read(project).liveRunId()); + assertEquals("beta", tools.dscode.control.protocol.WindowDriver.read(project).viewedRunId()); + assertEquals("agent-1", tools.dscode.control.protocol.WindowDriver.read(project).driverAgent()); + assertTrue(posted.get() != null && posted.get().contains("beta"), String.valueOf(posted.get())); + } finally { + if (ui.get() != null) ui.get().close(); + if (windowProcess.get() != null) windowProcess.get().destroyForcibly(); + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void closingTheWindowLeavesTheRunDirectoryAndTheShortLog() throws Exception { + Path run = writeRun("alpha", "ALPHA-RECORD"); + Path shortLog = project.resolve(".pickleball").resolve("agent-log"); + Files.createDirectories(shortLog.getParent()); + Files.writeString(shortLog, "keep-this-short-log\n"); + Process headless = startLongLivedChild(); + AtomicReference ui = new AtomicReference<>(); + AtomicReference windowProcess = new AtomicReference<>(); + try (FakeSession cli = FakeSession.start(project)) { + cli.setPid(headless.pid()); + Output opened = run( + new String[]{"open-window", project.toString(), "--run-id=alpha", "--agent=agent-1"}, + launch -> { + throw new AssertionError("open-window must not start a test"); + }, + (root, runId, agentId) -> { + FakeSession session = FakeSession.start( + root, "ui-attach", PickleballLocalLayout.attachFile(root)); + ui.set(session); + Process process = startLongLivedChild(); + windowProcess.set(process); + return process; + } + ); + assertEquals(0, opened.exitCode, opened.stderr + opened.stdout); + Map runBefore = snapshot(run); + byte[] logBefore = Files.readAllBytes(shortLog); + Output closed = run( + new String[]{"close-window", project.toString()}, + launch -> { + throw new AssertionError("close-window must not start a session"); + } + ); + assertEquals(0, closed.exitCode, closed.stderr + closed.stdout); + assertTrue(closed.stdout.contains("ACK WINDOW closed"), closed.stdout); + assertTrue(closed.stdout.contains("short log"), closed.stdout); + assertEquals(runBefore, snapshot(run)); + assertEquals(new String(logBefore, StandardCharsets.UTF_8), Files.readString(shortLog)); + assertTrue(headless.isAlive()); + assertTrue(Files.isRegularFile(PickleballLocalLayout.cliSessionState(project))); + assertFalse(tools.dscode.control.protocol.WindowDriver.read(project).open()); + } finally { + headless.destroyForcibly(); + if (ui.get() != null) ui.get().close(); + if (windowProcess.get() != null) windowProcess.get().destroyForcibly(); + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void aSecondAgentDoesNotBecomeASecondDriverOfTheLiveSession() throws Exception { + writeRun("alpha", "ALPHA-RECORD"); + writeRun("beta", "BETA-RECORD"); + AtomicReference ui = new AtomicReference<>(); + AtomicReference windowProcess = new AtomicReference<>(); + List posted = new ArrayList<>(); + try { + Output opened = run( + new String[]{"open-window", project.toString(), "--run-id=alpha", "--agent=agent-1"}, + launch -> { + throw new AssertionError("open-window must not start a test"); + }, + (root, runId, agentId) -> { + FakeSession session = FakeSession.start( + root, "ui-attach", PickleballLocalLayout.attachFile(root)); + session.onCommand = posted::add; + ui.set(session); + Process process = startLongLivedChild(); + windowProcess.set(process); + return process; + } + ); + assertEquals(0, opened.exitCode, opened.stderr + opened.stdout); + posted.clear(); + Output other = run( + new String[]{"open-window", project.toString(), "--run-id=beta", "--agent=agent-2"}, + launch -> { + throw new AssertionError("a second window must not be started"); + } + ); + assertEquals(0, other.exitCode, other.stderr + other.stdout); + assertTrue(other.stdout.contains("BETA-RECORD"), other.stdout); + assertTrue(other.stdout.contains("does not become a second driver"), other.stdout); + assertEquals("alpha", tools.dscode.control.protocol.WindowDriver.read(project).liveRunId()); + assertEquals("agent-1", tools.dscode.control.protocol.WindowDriver.read(project).driverAgent()); + posted.clear(); + Output play = run( + new String[]{"play", project.toString(), "--agent=agent-2", "--ack-only"}, + launch -> { + throw new AssertionError("a second agent must not start a session"); + } + ); + assertEquals(1, play.exitCode, play.stderr + play.stdout); + assertTrue(play.stderr.contains("does not become a second driver"), play.stderr); + assertTrue(posted.isEmpty(), posted.toString()); + assertEquals("agent-1", tools.dscode.control.protocol.WindowDriver.read(project).driverAgent()); + } finally { + if (ui.get() != null) ui.get().close(); + if (windowProcess.get() != null) windowProcess.get().destroyForcibly(); + } + } + + private Path writeRun(String id, String marker) throws IOException { + Path run = project.resolve(".pickleball").resolve("runs").resolve(id); + Files.createDirectories(run.resolve("reports")); + Files.createDirectories(run.resolve("config")); + Files.writeString(run.resolve("record.json"), "{\"runId\":\"" + id + "\",\"marker\":\"" + marker + "\"}\n"); + Files.writeString(run.resolve(id + ".log"), marker + "\n"); + Files.writeString(run.resolve("reports").resolve(id + "-report.txt"), marker + "\n"); + Files.writeString(run.resolve("config").resolve(id + ".properties"), "name=" + marker + "\n"); + return run; + } + + private static Map snapshot(Path root) throws IOException { + Map files = new LinkedHashMap<>(); + if (!Files.exists(root)) return files; + try (var walk = Files.walk(root)) { + List paths = walk.filter(Files::isRegularFile).sorted().toList(); + for (Path path : paths) { + files.put(root.relativize(path).toString(), HexFormat.of().formatHex(Files.readAllBytes(path))); + } + } + return files; + } + + private static Output run( + String[] args, + WorkbenchSessionCommands.DetachedStarter starter, + WorkbenchSessionCommands.UiStarter uiStarter + ) { + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + int exit = WorkbenchSessionCommands.run( + args, + new PrintStream(stdout, true, StandardCharsets.UTF_8), + new PrintStream(stderr, true, StandardCharsets.UTF_8), + starter, + uiStarter + ); + return new Output(exit, stdout.toString(StandardCharsets.UTF_8), stderr.toString(StandardCharsets.UTF_8)); + } + private static Output run(String[] args, WorkbenchSessionCommands.DetachedStarter starter) { ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); From 36e1e39483da776226581feeb0347ba877903cbc Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 22:47:40 +0000 Subject: [PATCH 10/23] Bottom line: the one Workbench window lists runs and shows the run a person or an agent selects. The dropdown and show-run read the same run directory. Closing the window releases the driver and does not delete the run or the short log. --- .../workbench/WorkbenchApplication.java | 23 +++- .../workbench/WorkbenchSessionActions.java | 63 ++++++++++ .../dscode/workbench/ui/WorkbenchFrame.java | 112 +++++++++++++++++- .../dscode/workbench/ui/WorkbenchUi.java | 6 +- .../WorkbenchSessionActionsTest.java | 82 +++++++++++++ 5 files changed, 278 insertions(+), 8 deletions(-) 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 68c0dda7e..f6eca912a 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchApplication.java @@ -11,6 +11,7 @@ import tools.dscode.workbench.mcp.WorkbenchMcpServer; import tools.dscode.workbench.sync.WorkbenchManifest; import tools.dscode.workbench.sync.WorkbenchSynchronizer; +import tools.dscode.control.protocol.WindowDriver; import tools.dscode.workbench.ui.WorkbenchUi; import tools.dscode.workbench.worker.WorkbenchLiveSession; import tools.dscode.workbench.worker.WorkbenchWorkerManager; @@ -184,8 +185,18 @@ private static int status(String[] args, PrintStream out) { } private static int ui(String[] args) { - Path project = requiredProject(args, "ui"); - WorkbenchUi.launch(project); + IsolateArgs parsed = projectCommandArgs(args, "ui"); + try { + if (parsed.runId() != null) { + WindowDriver.show(parsed.project(), parsed.runId(), parsed.agentId(), parsed.agentId() != null); + } else { + WindowDriver.markOpen(parsed.project()); + } + } catch (RuntimeException failure) { + System.err.println(failure.getMessage()); + return 1; + } + WorkbenchUi.launch(parsed.project(), parsed.runId(), parsed.agentId()); return 0; } @@ -206,7 +217,7 @@ static int isolate(String[] args, PrintStream out, PrintStream err, boolean inte if (!once && !interactiveStdin) { err.println("Controller isolate holds a paused worker on an interactive TTY."); err.println("Maven-exec isolate through PickleballWorkbenchLauncher starts a detached headless CLI session instead."); - err.println("Do not start the GUI."); + err.println("While testing for yourself, stay headless. Do not open the GUI for your own testing."); return 2; } @@ -218,7 +229,7 @@ static int isolate(String[] args, PrintStream out, PrintStream err, boolean inte out.println("Workbench isolate worker: pid=" + started.pid() + " scenario=" + started.scenarioId()); printReplayedRunVars(out, workerProperties); - out.println("Isolate stays one paused scenario. Do not start the GUI."); + out.println("Isolate stays one paused scenario. While testing for yourself, stay headless."); if (once) { requireCleanStop(live.stop()); return 0; @@ -688,8 +699,8 @@ private static void printUsage(PrintStream out) { out.println("live-check exercises raw Gherkin, Step Override, and live runtime operations on one persistent worker."); out.println("isolate holds a paused worker from the last Discover snapshot when stdin is an interactive TTY, or when pickleball.workbench.isolate.once is set."); out.println("session is the headless long-lived CLI controller: sync, start the Discover-snapshot worker, and serve 127.0.0.1 HTTP plus the same session commands the window uses. State is .pickleball/v//workbench/cli-session.json when current.json is complete, unless --session-file names that run's own session file. --run-id and --agent-id are copied onto the worker as pkb_run_id and pkb_agent_id. Two headless sessions do not share one session file."); - out.println("Consumer agents start session through PickleballWorkbenchLauncher isolate/session-start (detached) when nobody is watching. When a window is already open, only one agent drives it. Other agents stay headless on their own run ids. Do not start ui for agents."); + out.println("Consumer agents start session through PickleballWorkbenchLauncher isolate/session-start (detached) when testing for themselves. Do not open the GUI for your own testing. Open it with open-window --run-id to show a person a run you already have. That does not start a second test. Close it with close-window when you are done showing it. While testing for yourself, stay headless. When a window is already open, only one agent drives the live run. Other agents stay headless or attach read-only."); out.println("mcp serves the same Workbench services over protocol-only stdio; optional host wiring, not an agent setup step."); - 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 (versioned under v// when current.json is complete). The window calls the session commands; it does not keep a second copy of them."); + out.println("ui opens the one Swing Workbench over the same controller services and writes a localhost agent-attach endpoint to .pickleball/workbench/attach.json (versioned under v// when current.json is complete). Optional --run-id loads that run's record, logs, reports, and config. It does not start a test. The window calls the session commands; it does not keep a second copy of them or of the run data."); } } diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java index 24112bbb7..4401490a8 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/WorkbenchSessionActions.java @@ -2,6 +2,7 @@ import tools.dscode.control.protocol.ControlBridgeCallResult; import tools.dscode.control.protocol.ExampleRowSelector; +import tools.dscode.control.protocol.WindowDriver; import tools.dscode.workbench.catalog.ConsumerFeatureCatalog; import tools.dscode.workbench.lease.WorkbenchCallContext; import tools.dscode.workbench.lease.WorkbenchLeaseHolder; @@ -46,6 +47,24 @@ public final class WorkbenchSessionActions { "worker-start", "worker-restart", "worker-stop", + "stop", + "show-run", + "close-window" + ); + + private static final Set DRIVING = Set.of( + "open-scenario", + "example", + "play", + "from-here", + "pause", + "execute-step", + "insert-step", + "update-step", + "save", + "worker-start", + "worker-restart", + "worker-stop", "stop" ); @@ -301,6 +320,13 @@ private Map handle(String op, Map args) { if (!known(op)) { throw new IllegalArgumentException("Unknown session command: " + op); } + if (DRIVING.contains(op)) { + String refused = WindowDriver.refuseSecondDriver(services.projectRoot(), args.get("agent")).orElse(null); + if (refused != null) { + throw new IllegalStateException(refused); + } + WindowDriver.claimVacant(services.projectRoot(), args.get("agent")); + } return switch (op) { case "open-scenario" -> openScenario(args.get("feature"), args.get("name"), args.get("example")); case "example" -> example(first(args, "example", "text")); @@ -318,10 +344,41 @@ private Map handle(String op, Map args) { case "worker-restart" -> workerRestart(); case "worker-stop" -> workerStop(); case "stop" -> stopPlayback(); + case "show-run" -> showRun(first(args, "runId", "run"), args.get("agent"), Boolean.parseBoolean(args.get("claim"))); + case "close-window" -> closeWindow(); default -> throw new IllegalArgumentException("Unknown session command: " + op); }; } + public Map showRun(String runId, String agentId, boolean claim) { + WindowDriver.Decision decision = WindowDriver.show(services.projectRoot(), runId, agentId, claim); + if (window != null) { + show(client -> client.showRun(decision)); + } + LinkedHashMap payload = accepted("show-run", decision.readOnly() ? "READ_ONLY" : "SHOWN"); + payload.put("run", decision.loaded().runId()); + payload.put("record", decision.loaded().recordText()); + payload.put("logs", decision.loaded().logs().stream().map(Path::toString).toList()); + payload.put("reports", decision.loaded().reports().stream().map(Path::toString).toList()); + payload.put("config", decision.loaded().config().stream().map(Path::toString).toList()); + payload.put("liveRun", decision.state().liveRunId() == null ? "" : decision.state().liveRunId()); + payload.put("driver", decision.state().driverAgent() == null ? "" : decision.state().driverAgent()); + payload.put("driving", decision.driving()); + payload.put("readOnly", decision.readOnly()); + payload.put("startedTest", false); + return payload; + } + + public Map closeWindow() { + WindowDriver.closeWindow(services.projectRoot()); + if (window != null) { + show(Window::closeWindow); + } + LinkedHashMap payload = accepted("close-window", "CLOSED"); + payload.put("startedTest", false); + return payload; + } + private Map play(boolean fromHere, String stepId) { if (window != null) { show(client -> { @@ -515,5 +572,11 @@ public interface Window { void restartWorker(); void stopWorker(); + + default void showRun(WindowDriver.Decision decision) { + } + + default void closeWindow() { + } } } 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 f78f8742d..2c69cb5af 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 @@ -7,6 +7,8 @@ import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; import tools.dscode.control.protocol.PickleballLocalLayout; import tools.dscode.control.protocol.PickleballVersion; +import tools.dscode.control.protocol.RunView; +import tools.dscode.control.protocol.WindowDriver; import tools.dscode.control.protocol.ControlBridgeStepResolution; import tools.dscode.workbench.WorkbenchSessionActions; import tools.dscode.workbench.catalog.CatalogTargetResolver; @@ -92,6 +94,10 @@ final class WorkbenchFrame extends JFrame { private boolean liveIsolateWorkerSeen; private final Timer diagnosticTailTimer = new Timer(1200, event -> tailDiagnostics()); private final JComboBox reportPicker = new JComboBox<>(); + private final JComboBox runPicker = new JComboBox<>(); + private final JTextArea runViewText = outputArea(); + private final JLabel runLiveLabel = new JLabel("Live run: none"); + private boolean syncingRunPicker; private final JEditorPane reportView = new JEditorPane(); private final Map reportFiles = new LinkedHashMap<>(); @@ -176,10 +182,19 @@ final class WorkbenchFrame extends JFrame { private int shownTabIndex; WorkbenchFrame(WorkbenchUiController controller) { - this(controller, null); + this(controller, null, null, null); } WorkbenchFrame(WorkbenchUiController controller, WorkbenchAttachServer attach) { + this(controller, attach, null, null); + } + + WorkbenchFrame( + WorkbenchUiController controller, + WorkbenchAttachServer attach, + String initialRunId, + String initialAgentId + ) { super("Pickleball Workbench"); this.controller = controller; this.attach = attach; @@ -250,6 +265,7 @@ final class WorkbenchFrame extends JFrame { })); applyLease(controller.controlLease()); actions.installWindow(new SessionWindow()); + loadInitialRun(initialRunId, initialAgentId); controller.setUiGoHandler(link -> SwingUtilities.invokeLater(() -> applyGoResult(new WorkbenchGoResolver(controller.projectRoot()).resolve(link), link))); @@ -453,8 +469,91 @@ private JComponent stepPanel() { return panel; } + private JComponent runPanel() { + JPanel panel = new JPanel(new BorderLayout(8, 8)); + panel.setOpaque(false); + JPanel bar = new JPanel(new BorderLayout(8, 0)); + bar.setOpaque(false); + JLabel caption = new JLabel("Run"); + bar.add(caption, BorderLayout.WEST); + bar.add(runPicker, BorderLayout.CENTER); + bar.add(runLiveLabel, BorderLayout.EAST); + panel.add(bar, BorderLayout.NORTH); + runViewText.setText("Choose a run. This loads that run's record, logs, reports, and config. It does not start a test."); + panel.add(new JScrollPane(runViewText), BorderLayout.CENTER); + runPicker.addActionListener(event -> { + if (syncingRunPicker) return; + Object selected = runPicker.getSelectedItem(); + if (selected == null || selected.toString().isBlank()) return; + try { + actions.showRun(selected.toString(), null, false); + } catch (RuntimeException failure) { + runViewText.setText(failure.getMessage()); + } + }); + return panel; + } + + private void loadInitialRun(String runId, String agentId) { + refreshRunChooser(); + String id = runId; + if (id == null || id.isBlank()) { + id = WindowDriver.read(controller.projectRoot()).viewedRunId(); + } + if (id == null || id.isBlank()) return; + try { + boolean claim = agentId != null && !agentId.isBlank(); + actions.showRun(id, agentId, claim); + } catch (RuntimeException failure) { + runViewText.setText(failure.getMessage()); + } + } + + private void refreshRunChooser() { + java.util.List ids = RunView.runIds(controller.projectRoot()); + syncingRunPicker = true; + try { + Object selected = runPicker.getSelectedItem(); + runPicker.removeAllItems(); + for (String id : ids) runPicker.addItem(id); + if (selected != null) runPicker.setSelectedItem(selected.toString()); + } finally { + syncingRunPicker = false; + } + } + + private void presentRun(WindowDriver.Decision decision) { + refreshRunChooser(); + syncingRunPicker = true; + try { + runPicker.setSelectedItem(decision.loaded().runId()); + } finally { + syncingRunPicker = false; + } + String live = decision.state().liveRunId() == null ? "none" : decision.state().liveRunId(); + String mode = decision.readOnly() ? "read-only" : "live"; + runLiveLabel.setText("Live run: " + live + " (" + mode + ")"); + StringBuilder text = new StringBuilder(); + text.append(decision.loaded().recordText()); + if (!text.toString().endsWith("\n")) text.append('\n'); + text.append("\nlogs:\n"); + for (Path log : decision.loaded().logs()) text.append(log).append('\n'); + text.append("\nreports:\n"); + for (Path report : decision.loaded().reports()) text.append(report).append('\n'); + text.append("\nconfig:\n"); + for (Path config : decision.loaded().config()) text.append(config).append('\n'); + text.append("\nPlay, step, and stop apply only to the live run. Other runs are readable.\n"); + runViewText.setText(text.toString()); + runViewText.setCaretPosition(0); + if (rightTabs != null) { + int index = rightTabs.indexOfTab("Run"); + if (index >= 0) rightTabs.setSelectedIndex(index); + } + } + private JComponent rightWorkspace() { rightTabs = new JTabbedPane(); + rightTabs.addTab("Run", runPanel()); rightTabs.addTab("Mapping", mappingPanel()); rightTabs.addTab("Terminal", terminal); rightTabs.addTab("Explorer", diagnosticsPanel()); @@ -2779,6 +2878,7 @@ private JComponent advancedBreakpointsPanel() { } private void closeWorkbench() { + WindowDriver.closeWindow(controller.projectRoot()); diagnosticTailTimer.stop(); if (closing) return; closing = true; @@ -2910,6 +3010,16 @@ public void restartWorker() { public void stopWorker() { runStateAction("Stopping worker", controller::stopWorker); } + + @Override + public void showRun(WindowDriver.Decision decision) { + presentRun(decision); + } + + @Override + public void closeWindow() { + closeWorkbench(); + } } private static String defaultOverrideSource() { 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 6d9da0477..61a08579f 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 @@ -12,13 +12,17 @@ private WorkbenchUi() { } public static void launch(Path projectRoot) { + launch(projectRoot, null, null); + } + + public static void launch(Path projectRoot, String runId, String agentId) { Runnable show = () -> { WorkbenchTheme.install(); WorkbenchController services = new WorkbenchController(projectRoot); services.attachUi(); WorkbenchAttachServer attach = WorkbenchAttachServer.start(services, projectRoot); WorkbenchUiController controller = new WorkbenchUiController(projectRoot, services); - WorkbenchFrame frame = new WorkbenchFrame(controller, attach); + WorkbenchFrame frame = new WorkbenchFrame(controller, attach, runId, agentId); frame.setVisible(true); // JFXPanel starts JavaFX with a SecondaryLoop on this EDT. Defer it until // after the window is showing so the unnamed-module warning is not a hang. diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java index 7daca8bd3..837423f71 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/WorkbenchSessionActionsTest.java @@ -2,13 +2,18 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import tools.dscode.control.protocol.PickleballLocalLayout; +import tools.dscode.control.protocol.WindowDriver; import tools.dscode.workbench.lease.WorkbenchCallContext; import tools.dscode.workbench.lease.WorkbenchLeaseHolder; import tools.dscode.workbench.player.WorkbenchSaveResult; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -120,6 +125,73 @@ void windowReceivesEverySessionCommand() throws Exception { } } + @Test + void showRunLoadsTheRecordAndASecondAgentDoesNotDrive() throws Exception { + Path alpha = writeRun("alpha", "ALPHA-RECORD"); + writeRun("beta", "BETA-RECORD"); + Path shortLog = PickleballLocalLayout.root(project).resolve("agent-log"); + Files.createDirectories(shortLog.getParent()); + Files.writeString(shortLog, "keep-this-short-log\n"); + try (WorkbenchController controller = new WorkbenchController(project)) { + WorkbenchSessionActions actions = new WorkbenchSessionActions(controller); + RecordingWindow window = new RecordingWindow(alpha); + actions.installWindow(window); + Map before = snapshot(alpha); + + Map shown = actions.showRun("alpha", "agent-1", true); + assertEquals(Boolean.FALSE, shown.get("startedTest")); + assertTrue(String.valueOf(shown.get("record")).contains("ALPHA-RECORD")); + assertEquals("alpha", shown.get("liveRun")); + assertEquals("agent-1", shown.get("driver")); + assertTrue(window.calls.contains("show-run:alpha"), window.calls.toString()); + + Map other = actions.showRun("beta", null, false); + assertEquals(Boolean.FALSE, other.get("startedTest")); + assertTrue(String.valueOf(other.get("record")).contains("BETA-RECORD")); + assertEquals("alpha", other.get("liveRun")); + assertEquals(Boolean.TRUE, other.get("readOnly")); + assertEquals(before, snapshot(alpha)); + assertTrue(window.calls.contains("show-run:beta"), window.calls.toString()); + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> actions.dispatch("play", Map.of("agent", "agent-2")) + ); + assertTrue(refused.getMessage().contains("does not become a second driver")); + assertFalse(window.calls.contains("play")); + assertEquals("agent-1", WindowDriver.read(project).driverAgent()); + + byte[] logBefore = Files.readAllBytes(shortLog); + Map runBefore = snapshot(alpha); + actions.closeWindow(); + assertTrue(window.calls.contains("close-window")); + assertEquals(runBefore, snapshot(alpha)); + assertEquals(new String(logBefore), Files.readString(shortLog)); + assertFalse(WindowDriver.read(project).open()); + } + } + + private Path writeRun(String id, String marker) throws IOException { + Path run = PickleballLocalLayout.root(project).resolve("runs").resolve(id); + Files.createDirectories(run.resolve("reports")); + Files.createDirectories(run.resolve("config")); + Files.writeString(run.resolve("record.json"), "{\"runId\":\"" + id + "\",\"marker\":\"" + marker + "\"}\n"); + Files.writeString(run.resolve(id + ".log"), marker + "\n"); + Files.writeString(run.resolve("reports").resolve(id + "-report.txt"), marker + "\n"); + Files.writeString(run.resolve("config").resolve(id + ".properties"), "name=" + marker + "\n"); + return run; + } + + private static Map snapshot(Path root) throws IOException { + Map files = new LinkedHashMap<>(); + try (var walk = Files.walk(root)) { + for (Path path : walk.filter(Files::isRegularFile).sorted().toList()) { + files.put(root.relativize(path).toString(), HexFormat.of().formatHex(Files.readAllBytes(path))); + } + } + return files; + } + private static final class RecordingWindow implements WorkbenchSessionActions.Window { private final Path feature; private final List calls = new ArrayList<>(); @@ -218,5 +290,15 @@ public void restartWorker() { public void stopWorker() { calls.add("worker-stop"); } + + @Override + public void showRun(WindowDriver.Decision decision) { + calls.add("show-run:" + decision.loaded().runId()); + } + + @Override + public void closeWindow() { + calls.add("close-window"); + } } } From b6f6dc5f797a67594b6bb168c8ab7167e63a5319 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Thu, 1 Oct 2026 22:47:40 +0000 Subject: [PATCH 11/23] Bottom line: stay headless while testing; open the window only to show a person a run you already have. The old "do not start the GUI" rule is gone from the agent guide, workbench docs, bridges, and exported guidance. There is no Gherkin step that opens the window. --- AGENTS.md | 2 +- docs/agent/README.md | 2 +- docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 3 +++ docs/ai-run-configuration.md | 4 ++-- docs/configuration.md | 2 +- docs/consumer-agent-guide.md | 12 +++++++----- docs/consumer-project.md | 6 +++--- docs/pickleball-workbench.md | 19 ++++++++++++------- .../.github/copilot-instructions.md | 4 ++-- maven-consumer-project/AGENTS.md | 4 ++-- .../diagnostic/PickleballGuidanceChecks.java | 9 ++++++--- pickleball-workbench/AGENTS.md | 2 +- scripts/verify_agent_contract.py | 17 +++++++++++++++-- .../launcher/WorkbenchAgentCommands.java | 4 ++-- .../pickleball/guidance/AGENT-GUIDE.md | 12 +++++++----- .../pickleball/guidance/docs/agent/README.md | 2 +- .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 3 +++ .../guidance/docs/ai-run-configuration.md | 4 ++-- .../pickleball/guidance/docs/configuration.md | 2 +- .../guidance/docs/consumer-agent-guide.md | 12 +++++++----- .../guidance/docs/consumer-project.md | 6 +++--- .../guidance/docs/pickleball-workbench.md | 19 ++++++++++++------- .../launcher/WorkbenchAgentCommandsTest.java | 4 +++- .../WorkbenchPointerContractTest.java | 11 +++++++++-- 26 files changed, 108 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7eff2669..4d012fdb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,7 +183,7 @@ Only the separate consumer worker JVM executes Pickleball. `WorkbenchWorkerManag The published Pickleball JAR embeds the completed controller-only Workbench JAR as exactly one opaque payload at `META-INF/pickleball/workbench/pickleball-workbench.jar`. It must not flatten Workbench/MCP classes into the outer runtime, and the nested Workbench must not contain core. The ownership mnemonic is: **Pickleball may contain Workbench; Workbench must not contain Pickleball.** -The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not start the GUI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. ## Build and validation diff --git a/docs/agent/README.md b/docs/agent/README.md index 1ca179528..b71b2abe5 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -18,7 +18,7 @@ This directory supports repository-native AI coding agents. It is not a runtime Agent adapters should remain small and point back to the canonical contract rather than copying the full project description. -The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window; other agents stay headless. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. +The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window; other agents stay headless. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. The nested `/maven-consumer-project/.github/copilot-instructions.md` is identical to `AGENTS.md` so Copilot Chat sees the same Workbench pointer. diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 984dcf826..e6ecb8f25 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -49,6 +49,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. A `FAILED` `executeStep` result leaves that same paused worker available; it is not a worker-ending error. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. The agent does not start the GUI and does not click JavaFX or WebView. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not start the GUI to obtain it. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not open the GUI for your own testing to obtain it. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. 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 ed054b968..3568bf91a 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -513,9 +513,12 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballVersion.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlBridgeStepResolutionTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlProtocolStepSeedTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java` +- `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java` ## Pickleball Workbench module diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index a0e568a6f..1b42d9eeb 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -430,7 +430,7 @@ When operating in a consumer project: ## AI agents -The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not start the GUI. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. @@ -442,7 +442,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not start the GUI. +plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. After Discover, inspect `pkb_run_profile` from `run-catalog.json`, `run-index.json`, or `summary.json`. Confirm and live isolate replay that retained profile through `pkb_runvars` (LastDiscoverSnapshot) unless the snapshot is marked sealed, in which case the next worker launch uses `-Dpkb_overriderunvars=`. If there is no prior Discover snapshot, Workbench says so; it does not silently re-resolve from project defaults. The Workbench sealed-RunVars panel, if unused, changes no behavior; Apply writes a sealed snapshot for the **next** launch and does not mutate an in-flight worker. diff --git a/docs/configuration.md b/docs/configuration.md index d76fcac19..ddc791b7c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -120,7 +120,7 @@ The short log is `.pickleball/agent-log`. Read it before a run, after a run, and `inbox//` is a note for that agent. `inbox/any/` is a note any active agent may take. A note is one short file: who wrote it, timestamp, one message, optional path to a run record. The reader deletes the note after taking it. The inbox is not a second log and not an assignment framework. -When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. +When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, stop a headless run, or erase `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index bb253dfc3..d58309f04 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -6,7 +6,7 @@ A consumer project may contain only a short `AGENTS.md` bridge. That bridge uses ## Tool chooser -Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not start the GUI. Do not click the JavaFX or WebView UI. +Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. @@ -18,14 +18,16 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. + +The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. 1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. 2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. 3. **Live debug** — When nobody is watching, `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. When a Workbench window is already open, do not start another session. Drive that session with the same commands so the person sees the result: `open-scenario`, `example`, `play`, `from-here`, `execute-step`, `pause`, `stop`, `diagnostic-run`, and the worker, save, and refresh commands. `example` is the same Examples-row filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every match. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each launcher exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. -`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Agents must not start the GUI. If `workbench_*` tools already exist they are the same session, not a setup step. +`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. Do not copy consumer features into `.pickleball` as a sandbox. @@ -37,7 +39,7 @@ Read the short log `.pickleball/agent-log` before a run, after a run, and during ### Live isolation loop -After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. +After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. 1. `-Dexec.args=isolate` (or `session-start`) — starts the session from the last Discover snapshot and prints `ACK SESSION ...`. 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. @@ -60,7 +62,7 @@ After Discover has found the failing scenario, use the session that is already o Keep first-read small. After a successful export: 1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. 3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. 4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. 5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. diff --git a/docs/consumer-project.md b/docs/consumer-project.md index 01b34b64d..d590c5779 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot @@ -88,7 +88,7 @@ The test-scoped Pickleball dependency already contains its controller-only Workb .\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" ``` -Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Agents do not start the GUI and do not click it. When a person already has the window open, the same launcher commands drive that session so the person sees the result. When nobody is watching, `discover`, `confirm`, and `isolate` stay headless. Do not start the GUI in order to read `attach.json`. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. +Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has the window open, the same launcher commands drive that session so the person sees the result. When nobody is watching, `discover`, `confirm`, and `isolate` stay headless. Do not open the GUI for your own testing in order to read `attach.json`. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. Runner defaults include: @@ -159,7 +159,7 @@ Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a ./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11'" ``` -Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window. Other agents stay headless on their own run ids. +Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window. Other agents stay headless on their own run ids. After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 3ea82a910..198d9d094 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -45,7 +45,7 @@ META-INF/pickleball/workbench/pickleball-workbench.jar Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version. -Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures. When a Workbench window is already open, the same session commands (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the rest of the table below) drive that session so the person sees the result. When nobody is watching, `isolate` / `execute-step` / `status` / `events` / `stop` stay the headless path. Do not start the GUI. Do not click the JavaFX or WebView UI. +Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures. When a Workbench window is already open, the same session commands (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the rest of the table below) drive that session so the person sees the result. When nobody is watching, `isolate` / `execute-step` / `status` / `events` / `stop` stay the headless path. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Do not require a machine-wide Maven or Gradle install. Discover, confirm, and sync invoke that wrapper themselves. MCP and the launcher stay Pickleball actions. There is no general `mvn` or `gradle` tool. @@ -77,7 +77,7 @@ Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `g Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. -`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not start the GUI. +`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. ## The window is a client @@ -99,10 +99,15 @@ Every action that changes the open session already exists as a launcher command | Refresh status | `refresh` | | Synchronize the project | `session-sync` (the one-shot forwarded command remains `sync`) | | Start, restart, or stop the worker | `worker-start`, `worker-restart`, `worker-stop` | +| Show a run | `show-run` (`--run-id`) loads that run's record, logs, reports, and config. The run dropdown reads the same directory. It does not start a test. | +| Open the window on a run | `open-window` (`--run-id`) opens the one window on that run. It does not start a test. | +| Close the window | `close-window` closes the window. It does not delete the run directory, stop a headless run, or erase the short log. | + +There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. The open command stays on the launcher. Advanced inspector actions (breakpoints, Step Overrides, browser page and screenshot, service call, Mapping) already call `WorkbenchServices`, the same methods as the existing MCP tools. Take control, Allow, and Deny stay the existing lease tools. Do not add a pixel, WebView, or JavaFX click tool. -When a Workbench window is already open, an agent uses these same commands against that session so the person sees the result. The agent does not start the GUI and does not click the JavaFX or WebView UI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. +When a Workbench window is already open, an agent uses these same commands against that session so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build tools stay the project wrapper. Discover, confirm, and `session-sync` invoke `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. MCP and the launcher do not embed Maven or Gradle and do not expose a general `mvn` or `gradle` tool. @@ -123,7 +128,7 @@ Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` pri .\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Agents do not start the GUI and do not click it. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. +Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. @@ -366,7 +371,7 @@ Human **Save** uses the same service. After a picker scenario was loaded, Swing ### Driving a session a person is watching -The Swing window is a client of the session commands above. Consumer agents do not start that window and do not click the JavaFX or WebView UI. When a person already has it open, the agent runs those same launcher commands against that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. Those tools are the same session, not a second implementation and not a setup step. +The Swing window is a client of the session commands above. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has it open, the agent runs those same launcher commands against that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. Those tools are the same session, not a second implementation and not a setup step. 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: @@ -397,7 +402,7 @@ A Copilot or other MCP-style client finds that file in the consumer project, the Headless `java -cp : tools.dscode.workbench.WorkbenchApplication mcp ` stays stdio JSON-RPC only. That is optional host wiring, not an agent setup step. The consumer-agent live path is launcher `isolate` then `execute-step`. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. -A person may start `ui .` and watch the window. The agent then uses the session commands against that open session. Do not launch a second `mcp` process against the same live UI session, and do not start the GUI in order to obtain `.pickleball/workbench/attach.json`. +A person may start `ui .` and watch the window. The agent then uses the session commands against that open session. Do not launch a second `mcp` process against the same live UI session, and do not open the GUI for your own testing in order to obtain `.pickleball/workbench/attach.json`. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. ## MCP stdio @@ -407,7 +412,7 @@ Start the lightweight non-Spring MCP server for a consumer project. This is opti java -cp $workbenchCp tools.dscode.workbench.WorkbenchApplication mcp ".\maven-consumer-project" ``` -Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."` through `mvnw` or `mvnw.cmd`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures. When a window is already open they drive it with the session commands. When nobody is watching they use `isolate` / `execute-step`. Do not start the GUI. Do not click the JavaFX or WebView UI. Do not require a machine-wide Maven or Gradle install. +Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."` through `mvnw` or `mvnw.cmd`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures. When a window is already open they drive it with the session commands. When nobody is watching they use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Do not require a machine-wide Maven or Gradle install. The server uses the official Java MCP SDK core and stdio transport with the Jackson 2 JSON adapter. MCP dependencies are Workbench-only and are resolved onto the forked controller classpath at launch; they are not shaded into the thin nested JAR. Workbench deliberately does not use Spring Boot, Spring Framework, Spring AI, WebMVC, or Tomcat. diff --git a/maven-consumer-project/.github/copilot-instructions.md b/maven-consumer-project/.github/copilot-instructions.md index 511652b42..527dcd03e 100644 --- a/maven-consumer-project/.github/copilot-instructions.md +++ b/maven-consumer-project/.github/copilot-instructions.md @@ -1,8 +1,8 @@ Before any Pickleball work, run the project wrapper (`mvnw.cmd` on Windows, otherwise `mvnw`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar`) as `org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. Do not require a machine-wide Maven or Gradle install. -Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. Do not click the JavaFX or WebView UI. +Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. -When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. +Open a run with `open-window --run-id` or `show-run --run-id`, and close it with `close-window`. Opening loads that run and does not start a test. When the window is already open, drive the live run with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. diff --git a/maven-consumer-project/AGENTS.md b/maven-consumer-project/AGENTS.md index 511652b42..527dcd03e 100644 --- a/maven-consumer-project/AGENTS.md +++ b/maven-consumer-project/AGENTS.md @@ -1,8 +1,8 @@ Before any Pickleball work, run the project wrapper (`mvnw.cmd` on Windows, otherwise `mvnw`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar`) as `org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=export-guidance .pickleball"` and, after a successful export, follow `.pickleball/AGENT-GUIDE.md`. Do not require a machine-wide Maven or Gradle install. -Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not start the GUI. Do not click the JavaFX or WebView UI. +Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `--example`) to find failures. Same launcher; only change `-Dexec.args`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. -When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. +Open a run with `open-window --run-id` or `show-run --run-id`, and close it with `close-window`. Opening loads that run and does not start a test. When the window is already open, drive the live run with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java index d23f2b160..8e2965236 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/PickleballGuidanceChecks.java @@ -59,7 +59,10 @@ void dependencyPrintsCanonicalAgentGuide() { assertTrue(guide.contains("DiagnosticCli help")); assertTrue(guide.contains("**Discover**")); assertTrue(guide.contains("**Confirm**")); - assertTrue(guide.contains("Do not start the GUI")); + assertTrue(guide.contains("Do not open the GUI for your own testing")); + assertTrue(guide.contains("While testing for yourself, stay headless")); + assertFalse(guide.contains("Do not start the GUI")); + assertFalse(guide.toLowerCase(java.util.Locale.ROOT).contains("must never open the gui")); assertTrue(guide.contains("open-scenario")); assertTrue(guide.contains("mvnw")); assertTrue(guide.contains("mvnw.cmd")); @@ -342,8 +345,8 @@ void dependencyExportsVersionMatchedGuidanceAndManifest() throws Exception { assertTrue(consumerProject.contains("Version-matched reference snapshot")); assertTrue(consumerProject.contains("discover which scenarios fail") || consumerProject.contains("one front door")); - assertTrue(consumerProject.contains("Do not start the GUI") - || consumerProject.contains("one front door")); + assertTrue(consumerProject.contains("While testing for yourself, stay headless")); + assertFalse(consumerProject.contains("Do not start the GUI")); } finally { deleteTree(root); } diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index da7435eec..c4f868e33 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -109,7 +109,7 @@ Workbench provides: java -cp pickleball-workbench-.jar: tools.dscode.workbench.WorkbenchApplication mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the front door. When a Workbench window is already open, drive that session with the same commands the window calls so the person sees the result. When nobody is watching, launcher `isolate` / `execute-step` stay on the headless CLI session. Hosts may already wire `mcp .` as optional alias. Do not start the GUI and do not click the JavaFX or WebView UI. Do not embed Maven or Gradle in MCP or the launcher. If a tool needs a build, it invokes the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Workbench does not own that board; the consumer launcher does. A per-run `--session-file` is only a path plus `--run-id` / `--agent-id` strings copied onto the worker. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the front door. When a Workbench window is already open, drive that session with the same commands the window calls so the person sees the result. When nobody is watching, launcher `isolate` / `execute-step` stay on the headless CLI session. Hosts may already wire `mcp .` as optional alias. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Do not embed Maven or Gradle in MCP or the launcher. If a tool needs a build, it invokes the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Workbench does not own that board; the consumer launcher does. A per-run `--session-file` is only a path plus `--run-id` / `--agent-id` strings copied onto the worker. UI mode cannot share process stdout with stdio MCP. `ui` therefore starts a 127.0.0.1-only JSON attach facade (`WorkbenchAttachServer`) over the same tools and writes `.pickleball/workbench/attach.json` so a Copilot/MCP client can join the visible session. Bind localhost only. Do not launch a second `mcp` process against a running UI. diff --git a/scripts/verify_agent_contract.py b/scripts/verify_agent_contract.py index 98707f669..ffb232f4a 100644 --- a/scripts/verify_agent_contract.py +++ b/scripts/verify_agent_contract.py @@ -315,9 +315,22 @@ def validate_consumer_bridge(errors: list[str]) -> None: ) lowered = text.lower() - if "do not start the gui" not in lowered: + if "do not open the gui for your own testing" not in lowered: errors.append( - "Consumer guidance bridge must say not to start the GUI: " + relative + "Consumer guidance bridge must say not to open the GUI for the agent's own testing: " + + relative + ) + if "while testing for yourself, stay headless" not in lowered: + errors.append( + "Consumer guidance bridge must say to stay headless while testing: " + relative + ) + if ( + "do not start the gui" in lowered + or "must never open the gui" in lowered + or "must not start the gui" in lowered + ): + errors.append( + "Consumer guidance bridge must not say the agent must never open the GUI: " + relative ) for required in ( "hint", diff --git a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java index 5511c9b16..1291033ed 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java @@ -76,7 +76,7 @@ private static int hint(WorkbenchCommandLine.Parsed parsed, PrintStream out) { out.println(); printDryRunResolve(parsed.project(), plan.runVars(), false, out); out.println("Browser: " + plan.browser().browser() + " (" + plan.browser().reason() + ")."); - out.println("Multi-scenario Discover/Confirm use this high pkb_parallel. When a Workbench window is already open, only one agent drives that session with open-scenario, example, play, execute-step, stop, and diagnostic-run so the person sees it. Other agents stay headless on their own run ids. Otherwise isolate starts a headless session; then use execute-step / status / events / stop. Do not start the GUI. Same launcher, only change exec.args."); + out.println("Multi-scenario Discover/Confirm use this high pkb_parallel. When a Workbench window is already open, only one agent drives that session with open-scenario, example, play, execute-step, stop, and diagnostic-run so the person sees it. Other agents stay headless on their own run ids. Otherwise isolate starts a headless session; then use execute-step / status / events / stop. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Same launcher, only change exec.args."); out.println("Read the short log (.pickleball/agent-log) before and after a run, and during a long session, instead of the dense diagnostic log."); out.println("After Discover, confirm (and isolate/execute-step for live debug) replay the retained pkb_run_profile through pkb_runvars. Never supply pkb_run_profile as input."); out.println("Sealed runs are opt-in: resolve → inspect → complete map including the six context keys → pkb_overriderunvars. Do not mix with pkb_runvars or pkb_profile. Compare runProfileFingerprint after the sealed run."); @@ -364,7 +364,7 @@ private static int printCatalog( out.println("run-catalog.json: " + latest.catalog()); out.println("retained pkb_run_profile: " + latest.runProfile()); if (writeSnapshot) { - out.println("NEXT: confirm --tags/--name/--example. When a Workbench window is already open, drive that session with open-scenario, example, play, execute-step, stop, and diagnostic-run. Otherwise isolate, then execute-step / status / events / stop. Do not start the GUI."); + out.println("NEXT: confirm --tags/--name/--example. When a Workbench window is already open, drive that session with open-scenario, example, play, execute-step, stop, and diagnostic-run. Otherwise isolate, then execute-step / status / events / stop. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test."); } return mavenExit; } catch (Exception failure) { diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index bb253dfc3..d58309f04 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -6,7 +6,7 @@ A consumer project may contain only a short `AGENTS.md` bridge. That bridge uses ## Tool chooser -Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not start the GUI. Do not click the JavaFX or WebView UI. +Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. @@ -18,14 +18,16 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. + +The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. 1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. 2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. 3. **Live debug** — When nobody is watching, `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. When a Workbench window is already open, do not start another session. Drive that session with the same commands so the person sees the result: `open-scenario`, `example`, `play`, `from-here`, `execute-step`, `pause`, `stop`, `diagnostic-run`, and the worker, save, and refresh commands. `example` is the same Examples-row filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every match. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each launcher exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. -`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Agents must not start the GUI. If `workbench_*` tools already exist they are the same session, not a setup step. +`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. Do not copy consumer features into `.pickleball` as a sandbox. @@ -37,7 +39,7 @@ Read the short log `.pickleball/agent-log` before a run, after a run, and during ### Live isolation loop -After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. +After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. 1. `-Dexec.args=isolate` (or `session-start`) — starts the session from the last Discover snapshot and prints `ACK SESSION ...`. 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. @@ -60,7 +62,7 @@ After Discover has found the failing scenario, use the session that is already o Keep first-read small. After a successful export: 1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. 3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. 4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. 5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md index 1ca179528..b71b2abe5 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/agent/README.md @@ -18,7 +18,7 @@ This directory supports repository-native AI coding agents. It is not a runtime Agent adapters should remain small and point back to the canonical contract rather than copying the full project description. -The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window; other agents stay headless. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. +The nested `/maven-consumer-project/AGENTS.md` is a dependency-owned Workbench bootstrap plus a short Discover/Confirm/live-debug pointer. It materializes version-matched guidance through `PickleballWorkbenchLauncher export-guidance`, directs the consumer agent to `.pickleball/AGENT-GUIDE.md`, and tells agents to use the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Use Workbench `hint` / `discover` / `confirm`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step` / `status` / `events` / `stop`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window; other agents stay headless. Refresh/version/manifest semantics, authoring rules, configuration, diagnostics, and troubleshooting belong in the exported dependency guidance. The nested `/maven-consumer-project/.github/copilot-instructions.md` is identical to `AGENTS.md` so Copilot Chat sees the same Workbench pointer. 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 984dcf826..e6ecb8f25 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 @@ -49,6 +49,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. A `FAILED` `executeStep` result leaves that same paused worker available; it is not a worker-ending error. Step Override source is compiled and loaded worker-side. Normal live calls do not invoke Maven/Gradle, resynchronize, or restart the worker. -The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. The agent does not start the GUI and does not click JavaFX or WebView. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not start the GUI to obtain it. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not open the GUI for your own testing to obtain it. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. 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 ed054b968..3568bf91a 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 @@ -513,9 +513,12 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballVersion.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlBridgeStepResolutionTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlProtocolStepSeedTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java` +- `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java` ## Pickleball Workbench module diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index a0e568a6f..1b42d9eeb 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -430,7 +430,7 @@ When operating in a consumer project: ## AI agents -The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not start the GUI. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. @@ -442,7 +442,7 @@ pkb_loglevel=warn pkb_reportretention=failed ``` -plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not start the GUI. +plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. After Discover, inspect `pkb_run_profile` from `run-catalog.json`, `run-index.json`, or `summary.json`. Confirm and live isolate replay that retained profile through `pkb_runvars` (LastDiscoverSnapshot) unless the snapshot is marked sealed, in which case the next worker launch uses `-Dpkb_overriderunvars=`. If there is no prior Discover snapshot, Workbench says so; it does not silently re-resolve from project defaults. The Workbench sealed-RunVars panel, if unused, changes no behavior; Apply writes a sealed snapshot for the **next** launch and does not mutate an in-flight worker. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index d76fcac19..ddc791b7c 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -120,7 +120,7 @@ The short log is `.pickleball/agent-log`. Read it before a run, after a run, and `inbox//` is a note for that agent. `inbox/any/` is a note any active agent may take. A note is one short file: who wrote it, timestamp, one message, optional path to a run record. The reader deletes the note after taking it. The inbox is not a second log and not an assignment framework. -When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. +When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, stop a headless run, or erase `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index bb253dfc3..d58309f04 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -6,7 +6,7 @@ A consumer project may contain only a short `AGENTS.md` bridge. That bridge uses ## Tool chooser -Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not start the GUI. Do not click the JavaFX or WebView UI. +Pickleball Workbench is the one front door. It is a Java program launched from the consumer project, not a GUI requirement. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. @@ -18,14 +18,16 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, and `worker-stop` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. + +The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. 1. **Discover** — `-Dexec.args=discover` (optional `--tags` / `--name` / `--example` / `--retention`). Workbench applies complete AI `pkb_runvars`: browser ladder, high/auto parallel, diagnostic, warn, failed retention. Override retention with `--retention=all|failed|none` (`--retention ` also works). It runs the consumer test through the project wrapper. Do not start a live worker to run the whole suite. Then read `run-catalog.json` and the retained `pkb_run_profile`. 2. **Confirm** — `-Dexec.args=confirm --tags=... --name=... --example=...` with the same Discover snapshot (ordinary LastDiscoverSnapshot replayed as `pkb_runvars`) and narrow tags, name, and example rows. Never supply `pkb_run_profile` as input. A snapshot marked sealed is for the next worker/isolate launch as `-Dpkb_overriderunvars=`, not Confirm. `pkb_example` is not a tag. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those Examples rows in each selected scenario. 3. **Live debug** — When nobody is watching, `-Dexec.args=isolate` (alias `session-start`) starts one long-lived headless Workbench session from the last Discover snapshot and prints `ACK SESSION`. When a Workbench window is already open, do not start another session. Drive that session with the same commands so the person sees the result: `open-scenario`, `example`, `play`, `from-here`, `execute-step`, `pause`, `stop`, `diagnostic-run`, and the worker, save, and refresh commands. `example` is the same Examples-row filter as `pkb_example`. Play shows the first source-order match. Headless `pkb_example` still runs every match. Then `-Dexec.args=execute-step --text='...'`, `status`, `events`, and `stop`. Each launcher exec exits; the session stays up. 4. **Emit the human handoff, then edit real consumer source** — write `.pickleball/investigations//` then in chat print the six-line bottom-line block (Gherkin/business first) plus `.pickleball/investigations//report.html`. Do not dump MCP transcripts, TRACE, or PNG analysis. -`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Agents must not start the GUI. If `workbench_*` tools already exist they are the same session, not a setup step. +`hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. Do not copy consumer features into `.pickleball` as a sandbox. @@ -37,7 +39,7 @@ Read the short log `.pickleball/agent-log` before a run, after a run, and during ### Live isolation loop -After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not start the GUI. Do not click the JavaFX or WebView UI. +After Discover has found the failing scenario, use the session that is already open when a person is watching. The same commands update that window. When nobody is watching, `isolate` starts a headless Workbench session if one is not already healthy. Later launcher commands are one-shot HTTP clients against that session (127.0.0.1). Default `--wait` on `execute-step` prints `STILL_WORKING` while a browser wait is in flight, then `DONE SUCCESS|FAILED`. `--ack-only` prints ACK and exits. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. 1. `-Dexec.args=isolate` (or `session-start`) — starts the session from the last Discover snapshot and prints `ACK SESSION ...`. 2. `-Dexec.args=execute-step --text='...'` — queues Gherkin on the paused worker. `--ack-only` returns immediately; default `--wait` polls `status`. @@ -60,7 +62,7 @@ After Discover has found the failing scenario, use the session that is already o Keep first-read small. After a successful export: 1. Follow the consumer project's own instructions first; they remain authoritative for project-specific behavior. -2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not start the GUI. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +2. Stay in this guide's tool chooser: Workbench `discover` when the failing scenario is unknown, then `confirm` with narrow tags, name, and `pkb_example`. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` then `execute-step` / `status` / `events` / `stop`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run builds through the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. 3. Inspect the **real** consumer `pom.xml`, Pickleball runner subclass, features, configuration, data, mappings, and test support before changing them. 4. Open a specific exported guide only when that topic is needed, for example `docs/dynamic-steps.md`, `docs/diagnostic-reporting.md`, `docs/configuration.md`, or `docs/ai-run-configuration.md`. 5. Do not assume the Pickleball core source repository is present. A normal consumer may only have the Maven dependency. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index 01b34b64d..d590c5779 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not start the GUI. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot @@ -88,7 +88,7 @@ The test-scoped Pickleball dependency already contains its controller-only Workb .\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=discover" ``` -Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Agents do not start the GUI and do not click it. When a person already has the window open, the same launcher commands drive that session so the person sees the result. When nobody is watching, `discover`, `confirm`, and `isolate` stay headless. Do not start the GUI in order to read `attach.json`. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. +Optional host MCP uses `"-Dexec.args=mcp ."`. Humans who want the Swing player can pass `ui .` instead. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has the window open, the same launcher commands drive that session so the person sees the result. When nobody is watching, `discover`, `confirm`, and `isolate` stay headless. Do not open the GUI for your own testing in order to read `attach.json`. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. The launcher verifies and extracts the opaque payload beneath `.pickleball/v//workbench/controller//` when `current.json` is complete (legacy `.pickleball/workbench/` otherwise), resolves controller libraries into the matching `lib//`, then starts a separate Workbench JVM with `java -cp`. After export, `.pickleball/open/pickleball-workbench` scripts are relocatable openers that resolve a pickleball jar without a baked-in m2/version/project path. `java -jar pickleball-.jar ui .` is the same outer Main-Class. Workbench captures this project's compiled outputs and effective test runtime before creating a separate worker JVM. Only the worker loads the consumer-resolved Pickleball runtime; the Workbench artifact and process contain no core implementation. See `docs/pickleball-workbench.md` for commands, lifecycle, protocol compatibility, and isolation checks. The live-loop order lives in `.pickleball/AGENT-GUIDE.md`. Runner defaults include: @@ -159,7 +159,7 @@ Human `PickleballTests` defaults remain `pretty` and `@all`. Agents launching a ./mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=confirm --tags=@the-failing-tag --name='The failing scenario' --example='1 2 5 3.4 7-11'" ``` -Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not start the GUI. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window. Other agents stay headless on their own run ids. +Windows uses `.\mvnw.cmd` with the same arguments. When a Workbench window is already open, `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` drive that session. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. Only one agent drives an open window. Other agents stay headless on their own run ids. After the run, read `pkb_run_profile` from `run-catalog.json` / `run-index.json` / `summary.json`. That is the complete resolved RunVar list. Do not assume omitted `pkb_runvars` keys equal project `pickleball.properties`. 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 3ea82a910..198d9d094 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 @@ -45,7 +45,7 @@ META-INF/pickleball/workbench/pickleball-workbench.jar Run the small launcher from the consumer test classpath. For Maven consumers, this command requires no cache path, separate Workbench dependency, or separately selected version. -Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures. When a Workbench window is already open, the same session commands (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the rest of the table below) drive that session so the person sees the result. When nobody is watching, `isolate` / `execute-step` / `status` / `events` / `stop` stay the headless path. Do not start the GUI. Do not click the JavaFX or WebView UI. +Workbench is the consumer AI-agent front door. Agents use `export-guidance`, `hint`, `discover`, and `confirm` to find failures. When a Workbench window is already open, the same session commands (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the rest of the table below) drive that session so the person sees the result. When nobody is watching, `isolate` / `execute-step` / `status` / `events` / `stop` stay the headless path. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Do not require a machine-wide Maven or Gradle install. Discover, confirm, and sync invoke that wrapper themselves. MCP and the launcher stay Pickleball actions. There is no general `mvn` or `gradle` tool. @@ -77,7 +77,7 @@ Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `g Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. -`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not start the GUI. +`hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. ## The window is a client @@ -99,10 +99,15 @@ Every action that changes the open session already exists as a launcher command | Refresh status | `refresh` | | Synchronize the project | `session-sync` (the one-shot forwarded command remains `sync`) | | Start, restart, or stop the worker | `worker-start`, `worker-restart`, `worker-stop` | +| Show a run | `show-run` (`--run-id`) loads that run's record, logs, reports, and config. The run dropdown reads the same directory. It does not start a test. | +| Open the window on a run | `open-window` (`--run-id`) opens the one window on that run. It does not start a test. | +| Close the window | `close-window` closes the window. It does not delete the run directory, stop a headless run, or erase the short log. | + +There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. The open command stays on the launcher. Advanced inspector actions (breakpoints, Step Overrides, browser page and screenshot, service call, Mapping) already call `WorkbenchServices`, the same methods as the existing MCP tools. Take control, Allow, and Deny stay the existing lease tools. Do not add a pixel, WebView, or JavaFX click tool. -When a Workbench window is already open, an agent uses these same commands against that session so the person sees the result. The agent does not start the GUI and does not click the JavaFX or WebView UI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. +When a Workbench window is already open, an agent uses these same commands against that session so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build tools stay the project wrapper. Discover, confirm, and `session-sync` invoke `mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. MCP and the launcher do not embed Maven or Gradle and do not expose a general `mvn` or `gradle` tool. @@ -123,7 +128,7 @@ Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` pri .\mvnw.cmd -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.dscode.launcher.PickleballWorkbenchLauncher" "-Dexec.classpathScope=test" "-Dexec.args=mcp ." ``` -Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Agents do not start the GUI and do not click it. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. +Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. @@ -366,7 +371,7 @@ Human **Save** uses the same service. After a picker scenario was loaded, Swing ### Driving a session a person is watching -The Swing window is a client of the session commands above. Consumer agents do not start that window and do not click the JavaFX or WebView UI. When a person already has it open, the agent runs those same launcher commands against that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. Those tools are the same session, not a second implementation and not a setup step. +The Swing window is a client of the session commands above. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has it open, the agent runs those same launcher commands against that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. Headless `mcp .` is optional when the host already exposes `workbench_*` tools. Those tools are the same session, not a second implementation and not a setup step. 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: @@ -397,7 +402,7 @@ A Copilot or other MCP-style client finds that file in the consumer project, the Headless `java -cp : tools.dscode.workbench.WorkbenchApplication mcp ` stays stdio JSON-RPC only. That is optional host wiring, not an agent setup step. The consumer-agent live path is launcher `isolate` then `execute-step`. That client may hold the lease without a banner. Save is still a distinct explicit tool and never an implicit write. -A person may start `ui .` and watch the window. The agent then uses the session commands against that open session. Do not launch a second `mcp` process against the same live UI session, and do not start the GUI in order to obtain `.pickleball/workbench/attach.json`. +A person may start `ui .` and watch the window. The agent then uses the session commands against that open session. Do not launch a second `mcp` process against the same live UI session, and do not open the GUI for your own testing in order to obtain `.pickleball/workbench/attach.json`. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. ## MCP stdio @@ -407,7 +412,7 @@ Start the lightweight non-Spring MCP server for a consumer project. This is opti java -cp $workbenchCp tools.dscode.workbench.WorkbenchApplication mcp ".\maven-consumer-project" ``` -Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."` through `mvnw` or `mvnw.cmd`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures. When a window is already open they drive it with the session commands. When nobody is watching they use `isolate` / `execute-step`. Do not start the GUI. Do not click the JavaFX or WebView UI. Do not require a machine-wide Maven or Gradle install. +Or, from a Maven consumer test classpath, `"-Dexec.args=mcp ."` through `mvnw` or `mvnw.cmd`. That launcher is optional host wiring. Agents use Workbench `discover` / `confirm` to find failures. When a window is already open they drive it with the session commands. When nobody is watching they use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Do not require a machine-wide Maven or Gradle install. The server uses the official Java MCP SDK core and stdio transport with the Jackson 2 JSON adapter. MCP dependencies are Workbench-only and are resolved onto the forked controller classpath at launch; they are not shaded into the thin nested JAR. Workbench deliberately does not use Spring Boot, Spring Framework, Spring AI, WebMVC, or Tomcat. diff --git a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java index e9631f364..8d002e540 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java @@ -163,7 +163,9 @@ void discoverNextIsConfirmNotIsolateMavenExec() throws Exception { assertTrue(text.contains("execute-step")); assertTrue(text.contains("open-scenario")); assertTrue(text.contains("--example")); - assertTrue(text.contains("Do not start the GUI")); + assertTrue(text.contains("Do not open the GUI for your own testing")); + assertTrue(text.contains("While testing for yourself, stay headless")); + assertFalse(text.contains("Do not start the GUI")); assertFalse(text.contains("only controls")); } diff --git a/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java b/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java index a06039995..e7fe54877 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java @@ -34,7 +34,11 @@ void consumerPointersAreShortIdenticalAndNameTheCliSession() throws Exception { assertTrue(lowered.contains("confirm")); assertTrue(lowered.contains("isolate")); assertTrue(lowered.contains("execute-step")); - assertTrue(lowered.contains("do not start the gui")); + assertTrue(lowered.contains("do not open the gui for your own testing")); + assertTrue(lowered.contains("while testing for yourself, stay headless")); + assertFalse(lowered.contains("do not start the gui")); + assertFalse(lowered.contains("must never open the gui")); + assertFalse(lowered.contains("must not start the gui")); assertTrue(lowered.contains("exec.args")); assertFalse(lowered.contains("register mcp")); @@ -65,7 +69,10 @@ void agentGuidesDriveAnOpenWindowAndUseTheProjectWrapper() throws Exception { String text = Files.readString(guide); String lowered = text.toLowerCase(Locale.ROOT); assertTrue(lowered.contains("open-scenario"), guide.toString()); - assertTrue(lowered.contains("do not start the gui"), guide.toString()); + assertTrue(lowered.contains("do not open the gui for your own testing"), guide.toString()); + assertTrue(lowered.contains("while testing for yourself, stay headless"), guide.toString()); + assertFalse(lowered.contains("do not start the gui"), guide.toString()); + assertFalse(lowered.contains("must never open the gui"), guide.toString()); assertTrue(lowered.contains("mvnw"), guide.toString()); assertTrue(lowered.contains("machine-wide"), guide.toString()); assertTrue(lowered.contains("already open"), guide.toString()); From 5ce5c964c82bd107a1ca46fe7a093aee86f24310 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Fri, 2 Oct 2026 06:06:29 +0000 Subject: [PATCH 12/23] Bottom line: an IF branch inherits the step's table or doc string, and resolve-runvars is the launcher JVM, not Discover. --- docs/consumer-agent-guide.md | 2 + .../gherkin/messages/NGherkinFactory.java | 29 ++++- .../cucumber/core/runner/StepExtension.java | 13 +- .../messages/types/InlinePickleArgument.java | 59 ++++++++- .../reporting/diagnostic/DiagnosticCli.java | 3 +- .../phraseoperations/ActionOperations.java | 8 +- .../treeparsing/preparsing/ParsedLine.java | 42 +++++++ .../launcher/WorkbenchAgentCommands.java | 3 +- .../pickleball/guidance/AGENT-GUIDE.md | 2 + .../guidance/docs/consumer-agent-guide.md | 2 + .../ConditionalArgumentPickleTest.java | 86 +++++++++++++ .../ConditionalBranchArgumentTest.java | 113 ++++++++++++++++++ .../launcher/WorkbenchAgentCommandsTest.java | 27 +++++ 13 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java create mode 100644 src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index d58309f04..80833de74 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -29,6 +29,8 @@ The Workbench window is a view of a run you already have. `open-window --run-id= `hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. +Do not treat resolve-runvars as the environment Discover will use. + Do not copy consumer features into `.pickleball` as a sandbox. ### Run coordination diff --git a/src/main/java/io/cucumber/core/gherkin/messages/NGherkinFactory.java b/src/main/java/io/cucumber/core/gherkin/messages/NGherkinFactory.java index 3ae8d68c0..46b3bfe13 100644 --- a/src/main/java/io/cucumber/core/gherkin/messages/NGherkinFactory.java +++ b/src/main/java/io/cucumber/core/gherkin/messages/NGherkinFactory.java @@ -102,11 +102,36 @@ public static String getGherkinArgumentText(io.cucumber.core.gherkin.Step gherki } static String getInlineDataTableArgumentText(PickleStep pickleStep) { - if (pickleStep == null || !pickleStep.hasInlineArgument() || !"DT".equals(pickleStep.getInlineArgumentType())) { + if (pickleStep == null || !pickleStep.hasInlineArgument()) { return null; } - return inlineDataTableArgumentToGherkinText(pickleStep.getInlineArgumentText()); + return gherkinDataTableForInlineType( + pickleStep.getInlineArgumentType(), + pickleStep.getInlineArgumentText()); + } + + /** + * {@code DT} is only the table type name. Any other uppercase type stays + * readable as inline argument text and does not become a data table. + */ + public static String gherkinDataTableForInlineType(String type, String argumentText) { + if (!"DT".equals(type) || argumentText == null || argumentText.isBlank()) { + return null; + } + return inlineDataTableArgumentToGherkinText(argumentText); + } + + /** The step's real data table or doc string. Ignores an inline marker. */ + public static String realGherkinArgument(io.cucumber.core.gherkin.Step step) { + if (step == null) { + return ""; + } + PickleStep pickleStep = (PickleStep) getProperty(step, "pickleStep"); + if (pickleStep == null) { + return ""; + } + return argumentToGherkinText(pickleStep.getArgument().orElse(null)); } static String inlineDataTableArgumentToGherkinText(String inlineArgumentText) { diff --git a/src/main/java/io/cucumber/core/runner/StepExtension.java b/src/main/java/io/cucumber/core/runner/StepExtension.java index d738bc893..7d9fa2840 100644 --- a/src/main/java/io/cucumber/core/runner/StepExtension.java +++ b/src/main/java/io/cucumber/core/runner/StepExtension.java @@ -28,6 +28,7 @@ import static io.cucumber.core.gherkin.messages.NGherkinFactory.argumentToGherkinText; import static io.cucumber.core.gherkin.messages.NGherkinFactory.getGherkinArgumentText; +import static io.cucumber.core.gherkin.messages.NGherkinFactory.realGherkinArgument; import static io.cucumber.core.runner.GlobalState.getCurrentScenarioState; import static io.cucumber.core.runner.GlobalState.getGlobalEventBus; import static io.cucumber.core.runner.GlobalState.getRunningStep; @@ -358,9 +359,19 @@ public StepExtension modifyStepExtension(String newText) { return modifiedStep; } + public String inheritedGherkinArgument() { + return realGherkinArgument(pickleStepTestStep.getStep()); + } + public StepExtension createNewStepExtension(String stepText) { + return createNewStepExtension(stepText, ""); + } + + public StepExtension createNewStepExtension(String stepText, String gherkinArgument) { PickleStepTestStep newPickleStepTestStep = getPickleStepTestStepFromStrings( - pickleStepTestStep.getStep().getKeyword(), stepText, ""); + pickleStepTestStep.getStep().getKeyword(), + stepText, + gherkinArgument == null ? "" : gherkinArgument); StepExtension modifiedStep = new StepExtension(testCase, newPickleStepTestStep); modifiedStep.setStepParsingMap(getStepParsingMap()); modifiedStep.parentStep = parentStep; diff --git a/src/main/java/io/cucumber/messages/types/InlinePickleArgument.java b/src/main/java/io/cucumber/messages/types/InlinePickleArgument.java index 2a636d3b5..104d7ac09 100644 --- a/src/main/java/io/cucumber/messages/types/InlinePickleArgument.java +++ b/src/main/java/io/cucumber/messages/types/InlinePickleArgument.java @@ -6,6 +6,8 @@ import java.util.Map; import java.util.regex.Pattern; +import static tools.dscode.common.GlobalConstants.BOOK_END; + public final class InlinePickleArgument { private static final Pattern INLINE_ARGUMENT_MARKER = Pattern.compile("\\b([A-Z]+):::"); @@ -18,12 +20,12 @@ public static Extracted extract(String text) { return null; } - String strippedText = text.stripTrailing(); - if (!strippedText.endsWith("|")) { + String closed = closedInlineArgumentText(text); + if (closed == null) { return null; } - var matcher = INLINE_ARGUMENT_MARKER.matcher(strippedText); + var matcher = INLINE_ARGUMENT_MARKER.matcher(closed); String type = null; int markerStart = -1; int markerEnd = -1; @@ -37,15 +39,60 @@ public static Extracted extract(String text) { return null; } - String argumentText = strippedText.substring(markerEnd).strip(); - if (argumentText.isEmpty()) { + String argumentText = closed.substring(markerEnd).strip(); + if (argumentText.isEmpty() || !argumentText.endsWith("|")) { return null; } - String stepText = strippedText.substring(0, markerStart).stripTrailing(); + String stepText = closed.substring(0, markerStart).stripTrailing(); return new Extracted(stepText, type, argumentText); } + /** + * The marker must still end with {@code |}. A backtick wrap, bookend, quote, + * or the sentence period LineData appends may follow that pipe; those closers + * are not part of the argument text. + */ + private static String closedInlineArgumentText(String text) { + String value = text.stripTrailing(); + boolean changed = true; + while (changed && !value.isEmpty()) { + changed = false; + if (value.endsWith(".") && !value.endsWith("..")) { + String withoutPeriod = value.substring(0, value.length() - 1).stripTrailing(); + if (endsWithInlineCloser(withoutPeriod)) { + value = withoutPeriod; + changed = true; + continue; + } + } + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if (first == last && isInlineWrapper(first)) { + value = value.substring(1, value.length() - 1).strip(); + changed = true; + } + } + } + value = value.stripTrailing(); + return value.endsWith("|") ? value : null; + } + + private static boolean endsWithInlineCloser(String value) { + if (value.endsWith("|")) { + return true; + } + return !value.isEmpty() && isInlineWrapper(value.charAt(value.length() - 1)); + } + + private static boolean isInlineWrapper(char character) { + return character == '`' + || character == '\'' + || character == '"' + || character == BOOK_END.charAt(0); + } + public record Extracted(String stepText, String argumentType, String argumentText) { } diff --git a/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticCli.java b/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticCli.java index 069e9217d..f619c8be0 100644 --- a/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticCli.java +++ b/src/main/java/tools/dscode/common/reporting/diagnostic/DiagnosticCli.java @@ -137,7 +137,8 @@ private static void printDryRunResolve( body.put("runProfileFingerprint", resolved.fingerprint()); body.put("runVars", resolved.runVars()); body.put("provenance", resolved.provenance()); - out.println("Dry-run resolve (does not start tests or browsers):"); + out.println("Dry-run resolve of the launcher JVM, not the Discover worker (does not start tests, browsers, or Discover):"); + out.println("Do not treat resolve-runvars as the environment Discover will use. Trust the environment on the run record after Discover."); out.println(json().writeValueAsString(body)); out.println("Sealed launch uses -Dpkb_overriderunvars=, never -Dpkb_run_profile=."); out.println(); diff --git a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/ActionOperations.java b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/ActionOperations.java index 4548b63a2..fa0a2377b 100644 --- a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/ActionOperations.java +++ b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/ActionOperations.java @@ -43,6 +43,7 @@ import static tools.dscode.common.treeparsing.parsedComponents.phraseoperations.ElementMatching.processElementMatches; import static tools.dscode.common.util.StringUtilities.limitText; import static tools.dscode.common.util.datetime.TemporalValue.delta; +import static tools.dscode.common.treeparsing.preparsing.ParsedLine.gherkinArgumentForBranch; import static tools.dscode.coredefinitions.BrowserSteps.getCurrentDriver; public enum ActionOperations implements OperationsInterface { @@ -477,7 +478,12 @@ public void execute(PhraseData phraseData) { int repetition = phraseData.getRepetition(); phraseData.result = Attempt.runVoid(repetition, 500, () -> { ElementMatch firstElement = phraseData.getElementMatchAfterOperation(VALUE_TYPE); - StepExtension newStepExtension = getRunningStep().createNewStepExtension(firstElement.getValue().toString()); + StepExtension runningStep = getRunningStep(); + String stepText = firstElement.getValue().toString(); + String inherited = runningStep.inheritedGherkinArgument(); + StepExtension newStepExtension = runningStep.createNewStepExtension( + stepText, + gherkinArgumentForBranch(stepText, inherited)); newStepExtension.runAndGetReturnValue(); }); } diff --git a/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java b/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java index 0a0b95f7f..d202e2a19 100644 --- a/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java +++ b/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java @@ -1,6 +1,8 @@ package tools.dscode.common.treeparsing.preparsing; +import io.cucumber.core.gherkin.messages.NGherkinFactory; import io.cucumber.core.runner.StepExtension; +import io.cucumber.messages.types.InlinePickleArgument; import tools.dscode.common.assertions.AssertionChain; import tools.dscode.common.treeparsing.parsedComponents.PhraseData; @@ -50,7 +52,31 @@ public static String normalizeConditionalText(StepExtension stepExtension) { } stepExtension.addDefinitionFlag(BLOCK_CONDITIONAL, DEBUG_LOGGING, _DEBUG_LOGGING, IGNORE_CHILDREN_IF_FALSE); + input = appendStoredInlineMarker( + input, + stepExtension.getInlineArgumentType(), + stepExtension.getInlineArgumentText()); + return rewriteConditionalBranches(input); + } + + /** + * A marker peeled from the end of the whole IF line belongs to the last + * branch. Putting it back keeps branches from sharing one inline table. + */ + static String appendStoredInlineMarker(String input, String type, String argumentText) { + if (input == null || input.isBlank() + || type == null || type.isBlank() + || argumentText == null || argumentText.isBlank()) { + return input; + } + String marker = type.trim() + ":::" + argumentText.strip(); + if (input.contains(marker)) { + return input; + } + return input + " " + marker; + } + static String rewriteConditionalBranches(String input) { String returnText = ""; String metaText = input.endsWith(":") ? "" : META_TEXT_SEPARATOR + " BLOCK_CONDITIONAL " + META_TEXT_SEPARATOR; @@ -72,6 +98,22 @@ public static String normalizeConditionalText(StepExtension stepExtension) { return returnText; } + /** + * A branch {@code DT:::...|} overrides the IF line's data table or doc string + * for that branch only. Any other type, or no marker, keeps the inherited argument. + */ + public static String gherkinArgumentForBranch(String branchStepText, String inheritedGherkinArgument) { + String inherited = inheritedGherkinArgument == null ? "" : inheritedGherkinArgument; + InlinePickleArgument.Extracted extracted = InlinePickleArgument.extract(branchStepText); + if (extracted == null) { + return inherited; + } + String inlineTable = NGherkinFactory.gherkinDataTableForInlineType( + extracted.argumentType(), + extracted.argumentText()); + return inlineTable == null ? inherited : inlineTable; + } + private static final Pattern TOKEN_PATTERN = Pattern.compile("\\b(?:ELSE-IF:|ELSE:|IF:|THEN:)"); diff --git a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java index 1291033ed..4ad0e1090 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java @@ -107,7 +107,8 @@ private static void printDryRunResolve( values.put(PKB_props.PKB_RUN_VARS, recommendedRunVars); } PKB_props.ResolvedRunVars resolved = PKB_props.resolveRunVars(values, jvm); - out.println("Dry-run resolve (does not start tests or browsers):"); + out.println("Dry-run resolve of the launcher JVM, not the Discover worker (does not start tests, browsers, or Discover):"); + out.println("Do not treat resolve-runvars as the environment Discover will use. Trust the environment on the run record after Discover."); out.println("sealed=" + resolved.sealed()); out.println("pkb_run_profile=" + resolved.runProfile()); out.println("runProfileFingerprint=" + resolved.fingerprint()); diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index d58309f04..80833de74 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -29,6 +29,8 @@ The Workbench window is a view of a run you already have. `open-window --run-id= `hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. +Do not treat resolve-runvars as the environment Discover will use. + Do not copy consumer features into `.pickleball` as a sandbox. ### Run coordination diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index d58309f04..80833de74 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -29,6 +29,8 @@ The Workbench window is a view of a run you already have. `open-window --run-id= `hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. +Do not treat resolve-runvars as the environment Discover will use. + Do not copy consumer features into `.pickleball` as a sandbox. ### Run coordination diff --git a/src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java b/src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java new file mode 100644 index 000000000..b7bcacbe4 --- /dev/null +++ b/src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java @@ -0,0 +1,86 @@ +package io.cucumber.core.gherkin.messages; + +import io.cucumber.core.gherkin.Step; +import io.cucumber.messages.types.PickleStep; +import org.junit.jupiter.api.Test; +import tools.dscode.common.treeparsing.preparsing.ParsedLine; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static tools.dscode.common.util.Reflect.getProperty; + +class ConditionalArgumentPickleTest { + private static final String DEFAULT_TABLE = " | city |\n | Tempe |"; + private static final String DEFAULT_DOC = " \"\"\"\n hello from the if line\n \"\"\""; + + @Test + void realArgumentIgnoresAnInlineTableSoBranchesDoNotShareIt() throws Exception { + Step parent = NGherkinFactory.createGherkinMessagesPickle( + "* ", + "IF: ready THEN: enter one ELSE: enter two DT:::city|Paris|", + DEFAULT_TABLE).getSteps().getFirst(); + PickleStep pickleStep = (PickleStep) getProperty(parent, "pickleStep"); + assertEquals("DT", inline(pickleStep, "getInlineArgumentType")); + assertEquals("city|Paris|", inline(pickleStep, "getInlineArgumentText")); + assertFalse(parent.getText().contains("DT:::")); + + String realArgument = NGherkinFactory.realGherkinArgument(parent); + assertTrue(realArgument.contains("Tempe")); + assertFalse(realArgument.contains("Paris")); + assertTrue(NGherkinFactory.getGherkinArgumentText(parent).contains("Paris")); + + String restored = parent.getText() + " " + inline(pickleStep, "getInlineArgumentType") + + ":::" + inline(pickleStep, "getInlineArgumentText"); + assertTrue(restored.endsWith("DT:::city|Paris|")); + assertTrue(restored.contains("enter one")); + assertFalse(restored.substring(0, restored.indexOf("ELSE:")).contains("DT:::")); + } + + @Test + void innerStepInheritsADocStringAndOnlyDtBecomesADataTable() throws Exception { + Step parent = NGherkinFactory.createGherkinMessagesPickle( + "* ", + "IF: ready THEN: enter the note", + DEFAULT_DOC).getSteps().getFirst(); + String inherited = NGherkinFactory.realGherkinArgument(parent); + assertTrue(inherited.contains("hello from the if line")); + + Step child = NGherkinFactory.createGherkinMessagesPickle( + "* ", + "enter the note", + ParsedLine.gherkinArgumentForBranch("enter the note", inherited)).getSteps().getFirst(); + assertTrue(NGherkinFactory.realGherkinArgument(child).contains("hello from the if line")); + + Step noted = NGherkinFactory.createGherkinMessagesPickle( + "* ", + "enter the note NOTE:::hello|", + inherited).getSteps().getFirst(); + PickleStep noteStep = (PickleStep) getProperty(noted, "pickleStep"); + assertEquals("enter the note", noted.getText()); + assertEquals("NOTE", inline(noteStep, "getInlineArgumentType")); + assertEquals("hello|", inline(noteStep, "getInlineArgumentText")); + assertTrue(NGherkinFactory.getGherkinArgumentText(noted).contains("hello from the if line")); + assertFalse(NGherkinFactory.getGherkinArgumentText(noted).contains("NOTE:::")); + + Step overridden = NGherkinFactory.createGherkinMessagesPickle( + "* ", + "`enter the note DT:::city|Paris|`", + ParsedLine.gherkinArgumentForBranch("`enter the note DT:::city|Paris|`", inherited)) + .getSteps().getFirst(); + PickleStep overriddenStep = (PickleStep) getProperty(overridden, "pickleStep"); + assertEquals("enter the note", overridden.getText()); + assertEquals("DT", inline(overriddenStep, "getInlineArgumentType")); + assertEquals("city|Paris|", inline(overriddenStep, "getInlineArgumentText")); + String table = NGherkinFactory.realGherkinArgument(overridden); + assertTrue(table.contains("Paris")); + assertFalse(table.contains("hello from the if line")); + } + + private static String inline(PickleStep pickleStep, String method) throws Exception { + Method getter = pickleStep.getClass().getMethod(method); + return (String) getter.invoke(pickleStep); + } +} diff --git a/src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java b/src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java new file mode 100644 index 000000000..bd7e6be99 --- /dev/null +++ b/src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java @@ -0,0 +1,113 @@ +package tools.dscode.common.treeparsing.preparsing; + +import io.cucumber.core.gherkin.messages.NGherkinFactory; +import io.cucumber.messages.types.InlinePickleArgument; +import org.junit.jupiter.api.Test; +import tools.dscode.common.GlobalConstants; +import tools.dscode.common.mappings.QuoteParser; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConditionalBranchArgumentTest { + private static final String DEFAULT_TABLE = " | city |\n | Tempe |"; + private static final String DEFAULT_DOC = " \"\"\"\n hello from the if line\n \"\"\""; + + @Test + void extractKeepsTypeAndRawTextUntilACallerParsesATable() { + InlinePickleArgument.Extracted extracted = InlinePickleArgument.extract( + "enter the row DT:::\"city\":Paris|"); + + assertEquals("enter the row", extracted.stepText()); + assertEquals("DT", extracted.argumentType()); + assertEquals("\"city\":Paris|", extracted.argumentText()); + assertNull(NGherkinFactory.gherkinDataTableForInlineType("NOTE", "hello|")); + + String parsed = NGherkinFactory.gherkinDataTableForInlineType("DT", extracted.argumentText()); + assertTrue(parsed.contains("city")); + assertTrue(parsed.contains("Paris")); + } + + @Test + void backtickWrapStillPeelsAMarkerThatEndsWithAPipe() { + assertExtracts("enter the name DT:::city|Paris|"); + assertExtracts("`enter the name DT:::city|Paris|`"); + assertExtracts("`enter the name DT:::city|Paris|`."); + assertExtracts(GlobalConstants.BOOK_END + "`enter the name DT:::city|Paris|`" + GlobalConstants.BOOK_END); + assertNull(InlinePickleArgument.extract( + "IF: ready THEN: enter the name DT:::city|Paris| ELSE: skip")); + assertNull(InlinePickleArgument.extract("click the button.")); + } + + @Test + void conditionalOperatorStripDoesNotRemoveTheMarkerFromExecutedText() { + String markerOnly = "enter the name DT:::a|b|"; + assertEquals(markerOnly, LineData.wrapLooseConditionalExpression(markerOnly)); + + String compared = LineData.wrapLooseConditionalExpression("if the value = 1 DT:::a|b|"); + assertTrue(compared.contains("DT:::a|b|")); + } + + @Test + void eachBranchKeepsItsOwnInlineTableAndOtherwiseInheritsTheStepArgument() { + String rewritten = ParsedLine.rewriteConditionalBranches( + "IF: ready THEN: enter the name DT:::city|Paris| ELSE: skip the field"); + assertTrue(rewritten.contains("run step `enter the name DT:::city|Paris|`")); + assertTrue(rewritten.contains("run step `skip the field`")); + assertFalse(rewritten.contains("skip the field DT:::")); + + List branches = backtickValues(rewritten); + String withMarker = branches.stream().filter(text -> text.contains("DT:::")).findFirst().orElseThrow(); + String withoutMarker = branches.stream().filter(text -> !text.contains("DT:::")).findFirst().orElseThrow(); + + String override = ParsedLine.gherkinArgumentForBranch(withMarker, DEFAULT_TABLE); + String inherited = ParsedLine.gherkinArgumentForBranch(withoutMarker, DEFAULT_TABLE); + assertTrue(override.contains("Paris")); + assertFalse(override.contains("Tempe")); + assertTrue(inherited.contains("Tempe")); + assertFalse(inherited.contains("Paris")); + assertEquals(DEFAULT_DOC, ParsedLine.gherkinArgumentForBranch("enter the note", DEFAULT_DOC)); + assertFalse(ParsedLine.gherkinArgumentForBranch("enter the note DT:::city|Paris|", DEFAULT_DOC) + .contains("hello from the if line")); + assertEquals(DEFAULT_TABLE, ParsedLine.gherkinArgumentForBranch("enter the note NOTE:::hello|", DEFAULT_TABLE)); + } + + @Test + void peeledMarkerIsRestoredOntoTheLastBranchOnly() { + String restored = ParsedLine.appendStoredInlineMarker( + "IF: ready THEN: enter one ELSE: enter two", + "DT", + "city|Paris|"); + List branches = backtickValues(ParsedLine.rewriteConditionalBranches(restored)); + assertEquals(2, branches.size()); + assertFalse(branches.get(0).contains("DT:::")); + assertTrue(branches.get(1).contains("DT:::city|Paris|")); + + String first = ParsedLine.gherkinArgumentForBranch(branches.get(0), DEFAULT_TABLE); + String last = ParsedLine.gherkinArgumentForBranch(branches.get(1), DEFAULT_TABLE); + assertTrue(first.contains("Tempe")); + assertFalse(first.contains("Paris")); + assertTrue(last.contains("Paris")); + assertFalse(last.contains("Tempe")); + } + + private static void assertExtracts(String text) { + InlinePickleArgument.Extracted extracted = InlinePickleArgument.extract(text); + assertEquals("enter the name", extracted.stepText()); + assertEquals("DT", extracted.argumentType()); + assertEquals("city|Paris|", extracted.argumentText()); + } + + private static List backtickValues(String rewritten) { + String lined = rewritten.stripTrailing(); + char last = lined.charAt(lined.length() - 1); + if (",;:.!?".indexOf(last) < 0) { + lined = lined + "."; + } + return new QuoteParser(lined).entriesBacktick().stream().map(entry -> entry.getValue()).toList(); + } +} diff --git a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java index 8d002e540..d1efeee9b 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java @@ -46,6 +46,11 @@ void hintPrintsLadderRunVarsAndNextDiscover() throws Exception { assertTrue(output.stdout().contains("pkb_reportretention=failed")); assertTrue(output.stdout().contains("NEXT: run discover")); assertTrue(output.stdout().contains("Dry-run resolve")); + assertTrue(output.stdout().contains("launcher JVM")); + assertTrue(output.stdout().contains("not the Discover worker")); + assertTrue(output.stdout().contains("Do not treat resolve-runvars as the environment Discover will use")); + assertTrue(output.stdout().contains("run record after Discover")); + assertFalse(output.stdout().contains("pkb_runvarssealed")); assertTrue(output.stdout().contains("pkb_overriderunvars") || output.stdout().contains("sealed=")); assertTrue(output.stdout().contains("provenance=")); assertFalse(output.stdout().contains("MUST")); @@ -66,12 +71,34 @@ void resolveRunVarsPrintsDryRunWithoutStartingTests() throws Exception { assertEquals(0, output.exitCode()); assertTrue(output.stdout().contains("Dry-run resolve")); + assertTrue(output.stdout().contains("launcher JVM")); + assertTrue(output.stdout().contains("not the Discover worker")); + assertTrue(output.stdout().contains("Do not treat resolve-runvars as the environment Discover will use")); + assertTrue(output.stdout().contains("run record after Discover")); + assertFalse(output.stdout().contains("pkb_runvarssealed")); assertTrue(output.stdout().contains("sealed=")); assertTrue(output.stdout().contains("pkb_overriderunvars")); assertTrue(output.stdout().contains("provenance=")); assertFalse(output.stdout().contains("NEXT: run discover")); } + @Test + void agentGuideSaysResolveRunVarsIsNotTheDiscoverEnvironment() throws Exception { + String line = "Do not treat resolve-runvars as the environment Discover will use."; + for (String path : List.of( + "docs/consumer-agent-guide.md", + "src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md", + "src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md" + )) { + String text = Files.readString(Path.of(path)); + assertTrue(text.contains(line), path); + assertFalse(text.contains("pkb_runvarssealed"), path); + assertTrue(text.contains("pkb_overriderunvars"), path); + assertTrue(text.contains("Never supply `pkb_run_profile` as input") + || text.contains("Never supply pkb_run_profile as input"), path); + } + } + @Test void exportGuidanceUsesJdkLocalStore() throws Exception { Path outputDir = tempDir.resolve("guidance"); From a31c9361d8bb60b1ecb44fdaf9277bbc681ea208 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Fri, 2 Oct 2026 00:12:54 -0700 Subject: [PATCH 13/23] Bottom line: example rows follow a partial scenario name, a peeled IF marker stays on the last branch, and Discover launches the project wrapper. pkb_example uses Cucumber's name find(), so a partial name still drops the other Examples rows. A marker peeled from an IF line is put back on the last branch even when an earlier branch has the same text. Discover and confirm launch mvnw, gradlew, or java -jar on that directory's wrapper, and Windows .cmd and .bat files go through cmd.exe. Sync treats a wrapper jar as the build root for a child module. --- docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 2 + docs/ai-run-configuration.md | 2 +- docs/block-conditionals.md | 8 +++ docs/configuration.md | 2 +- docs/consumer-agent-guide.md | 2 +- docs/consumer-project.md | 2 +- docs/pickleball-workbench.md | 2 +- .../workbench/sync/WorkbenchProject.java | 4 +- .../sync/WorkbenchSynchronizerTest.java | 16 ++++++ .../diagnostic/ConsumerMavenTestRunner.java | 54 +++++++++++++++---- .../treeparsing/preparsing/ParsedLine.java | 4 +- .../dscode/testengine/ExampleRowFilter.java | 4 +- .../pickleball/guidance/AGENT-GUIDE.md | 2 +- .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 2 + .../guidance/docs/ai-run-configuration.md | 2 +- .../guidance/docs/block-conditionals.md | 8 +++ .../pickleball/guidance/docs/configuration.md | 2 +- .../guidance/docs/consumer-agent-guide.md | 2 +- .../guidance/docs/consumer-project.md | 2 +- .../guidance/docs/pickleball-workbench.md | 2 +- .../ConsumerMavenTestRunnerTest.java | 43 ++++++++++++++- .../ConditionalBranchArgumentTest.java | 23 ++++++++ .../testengine/ExampleRowFilterTest.java | 25 +++++++++ 25 files changed, 192 insertions(+), 27 deletions(-) diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index e6ecb8f25..6fc71e728 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -22,7 +22,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation | `nested-and-block-conditionals.feature` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 3568bf91a..296e28d91 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -403,11 +403,13 @@ This inventory helps coding agents discover relevant files. It does not replace ## Framework tests +- `src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java` - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` +- `src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java` - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballArtifactLocatorTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java` diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index 1b42d9eeb..e39388551 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -370,7 +370,7 @@ PKB_props.overrideRunVars(Map.of( )); ``` -Dry-run resolve does **not** start tests or browsers: +Dry-run resolve does **not** start tests or browsers. `DiagnosticCli resolve-runvars` and Workbench `hint` preview the launcher JVM, not the Discover worker. Do not treat that preview, including its `pkb_environment`, as the environment Discover will use. Trust the run record after Discover. ```java PKB_props.ResolvedRunVars preview = PKB_props.resolveRunVars(values); diff --git a/docs/block-conditionals.md b/docs/block-conditionals.md index 1da9181d7..514513c16 100644 --- a/docs/block-conditionals.md +++ b/docs/block-conditionals.md @@ -71,6 +71,14 @@ Use `THEN:` when the result fits on one line: Branches are considered from left to right. Only the first matching branch runs. +## Data tables and doc strings + +An `IF:` / `THEN:` / `ELSE-IF:` / `ELSE:` branch that runs as its own step inherits the step's data table or doc string. A `THEN:` or `ELSE:` action that does not already start with a comma is run that way. A comma action stays in the IF step and uses that same table or doc string. + +A branch's own `DT:::...|` inline table replaces the inherited argument for that branch only. Any other inline type keeps the inherited argument. + +A marker peeled off the end of the whole line is put back on the last branch only. Earlier branches keep the step's data table or doc string. That still happens when an earlier branch already contains the same marker text. + ## Multi-step branches End the branch with a colon and place its work beneath it: diff --git a/docs/configuration.md b/docs/configuration.md index ddc791b7c..cf3e6b4e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -284,7 +284,7 @@ Dry-run without starting tests: PKB_props.ResolvedRunVars preview = PKB_props.resolveRunVars(values); ``` -`DiagnosticCli resolve-runvars` and Workbench `hint` print that preview, including per-key provenance (`override`, `runvars`, `profile`, `jvm`, `inherited-context`, `default`, `properties`). Agents resolve → inspect → complete map → `pkb_overriderunvars` → compare fingerprint. +`DiagnosticCli resolve-runvars` and Workbench `hint` print that preview, including per-key provenance (`override`, `runvars`, `profile`, `jvm`, `inherited-context`, `default`, `properties`). The preview is the launcher JVM, not the Discover worker. Do not treat its `pkb_environment` as the environment Discover will use. Trust the run record after Discover. Agents resolve → inspect → complete map → `pkb_overriderunvars` → compare fingerprint. Sealed input is `pkb_overriderunvars` only. ### Templates and runtime configs diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 80833de74..08e2eb665 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -29,7 +29,7 @@ The Workbench window is a view of a run you already have. `open-window --run-id= `hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. -Do not treat resolve-runvars as the environment Discover will use. +Do not treat resolve-runvars as the environment Discover will use. That preview is the launcher JVM, not the Discover worker. Trust the run record after Discover. Do not copy consumer features into `.pickleball` as a sandbox. diff --git a/docs/consumer-project.md b/docs/consumer-project.md index d590c5779..65530dc4d 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 198d9d094..9b2c0fed4 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -113,7 +113,7 @@ Build tools stay the project wrapper. Discover, confirm, and `session-sync` invo `hint`, `discover`, and `confirm` accept `--tags` / `--name` / `--example` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This retention overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. -Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` print a dry-run resolve preview (`PKB_props.resolveRunVars`) that does not start tests. Optional sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map including the six context keys → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. When a LastDiscoverSnapshot is marked sealed, the next worker launch uses `-Dpkb_overriderunvars=`, never `-Dpkb_run_profile=`. Unused, sealed input changes no behavior. +Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` print a dry-run resolve preview of the launcher JVM, not the Discover worker (`PKB_props.resolveRunVars`). It does not start tests. Do not treat that preview, including its `pkb_environment`, as the environment Discover will use. Trust the run record after Discover. Optional sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map including the six context keys → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. When a LastDiscoverSnapshot is marked sealed, the next worker launch uses `-Dpkb_overriderunvars=`, never `-Dpkb_run_profile=`. Unused, sealed input changes no behavior. `mcp` and `ui` remain host/human commands. Hosts that already run Workbench MCP can launch it from the consumer test classpath: diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java index 9a6fcfa9a..066dce43f 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchProject.java @@ -76,8 +76,10 @@ private static boolean hasWrapper(Path directory, Type type) { return type == Type.MAVEN ? Files.isRegularFile(directory.resolve("mvnw")) || Files.isRegularFile(directory.resolve("mvnw.cmd")) + || Files.isRegularFile(wrapperJar(directory, type)) : Files.isRegularFile(directory.resolve("gradlew")) - || Files.isRegularFile(directory.resolve("gradlew.bat")); + || Files.isRegularFile(directory.resolve("gradlew.bat")) + || Files.isRegularFile(wrapperJar(directory, type)); } static boolean isWindows() { diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java index 8814df840..4d55eaf94 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java @@ -184,10 +184,26 @@ void wrapperJarIsUsedWhenTheScriptIsAbsent() throws Exception { Path jar = maven.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.jar"); Files.writeString(jar, "jar"); assertEquals(jar, WorkbenchProject.locate(maven).launcher()); + Path module = maven.resolve("module"); + Files.createDirectories(module); + Files.writeString(module.resolve("pom.xml"), ""); + assertEquals(jar, WorkbenchProject.locate(module).launcher()); List command = WorkbenchSynchronizer.executableCommand(jar, List.of("test")); assertEquals("-jar", command.get(1)); assertEquals(jar.toString(), command.get(2)); assertEquals("test", command.get(3)); + + Path gradle = tempDir.resolve("jar-gradle"); + Path gradleModule = gradle.resolve("module"); + Files.createDirectories(gradleModule); + Files.writeString(gradleModule.resolve("build.gradle"), "plugins { id 'java' }\n"); + Path gradleJar = gradle.resolve("gradle").resolve("wrapper").resolve("gradle-wrapper.jar"); + Files.createDirectories(gradleJar.getParent()); + Files.writeString(gradleJar, "jar"); + assertEquals(gradleJar, WorkbenchProject.locate(gradleModule).launcher()); + List gradleCommand = WorkbenchSynchronizer.executableCommand(gradleJar, List.of("test")); + assertEquals("-jar", gradleCommand.get(1)); + assertEquals(gradleJar.toString(), gradleCommand.get(2)); } @Test diff --git a/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java b/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java index 3f22cf079..7ea992bc0 100644 --- a/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java +++ b/src/main/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunner.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Locale; -/** Invokes the consumer Maven wrapper. A machine-wide Maven install is not used. */ +/** Invokes the consumer Maven or Gradle wrapper. A machine-wide install is not used. */ public final class ConsumerMavenTestRunner { private ConsumerMavenTestRunner() { } @@ -64,28 +64,62 @@ public static int run( static Path wrapper(Path projectRoot) { Path project = projectRoot.toAbsolutePath().normalize(); - boolean windows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); - Path script = project.resolve(windows ? "mvnw.cmd" : "mvnw"); - if (Files.isRegularFile(script)) return script; - Path alternate = project.resolve(windows ? "mvnw" : "mvnw.cmd"); - if (Files.isRegularFile(alternate)) return alternate; - Path jar = project.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.jar"); + boolean mavenFile = Files.isRegularFile(project.resolve("pom.xml")); + boolean gradleFile = Files.isRegularFile(project.resolve("build.gradle")) + || Files.isRegularFile(project.resolve("build.gradle.kts")); + if (mavenFile && gradleFile) { + throw new IllegalArgumentException( + "Discover requires one Maven or Gradle project: " + project + + ". Pickleball does not use a machine-wide Maven or Gradle install." + ); + } + if (!mavenFile && !gradleFile) { + return findLauncher(project, true); + } + return findLauncher(project, mavenFile); + } + + private static Path findLauncher(Path buildRoot, boolean maven) { + boolean windows = isWindows(); + String windowsName = maven ? "mvnw.cmd" : "gradlew.bat"; + String unixName = maven ? "mvnw" : "gradlew"; + Path windowsPath = buildRoot.resolve(windowsName); + Path unixPath = buildRoot.resolve(unixName); + if (windows && Files.isRegularFile(windowsPath)) return windowsPath; + if (Files.isRegularFile(unixPath)) return unixPath; + if (Files.isRegularFile(windowsPath)) return windowsPath; + Path jar = wrapperJar(buildRoot, maven); if (Files.isRegularFile(jar)) return jar; + String script = windows ? windowsName : unixName; + String kind = maven ? "Maven" : "Gradle"; throw new IllegalArgumentException( - "No Maven wrapper at " + project - + ". Expected mvnw or mvnw.cmd, or .mvn/wrapper/maven-wrapper.jar." - + " Pickleball does not use a machine-wide Maven install." + "No " + kind + " wrapper at " + buildRoot + + ". Expected " + script + " or " + jar + + ". Pickleball does not use a machine-wide Maven or Gradle install." ); } + private static Path wrapperJar(Path buildRoot, boolean maven) { + return maven + ? buildRoot.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.jar") + : buildRoot.resolve("gradle").resolve("wrapper").resolve("gradle-wrapper.jar"); + } + static List launchPrefix(Path launcher) { String name = launcher.getFileName() == null ? launcher.toString() : launcher.getFileName().toString(); if (name.endsWith(".jar")) { return List.of(javaExecutable(), "-jar", launcher.toString()); } + if (isWindows() && (name.endsWith(".cmd") || name.endsWith(".bat"))) { + return List.of("cmd.exe", "/d", "/c", launcher.toString()); + } return List.of(launcher.toString()); } + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + } + private static String javaExecutable() { boolean windows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); Path bin = Path.of(System.getProperty("java.home"), "bin", windows ? "java.exe" : "java"); diff --git a/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java b/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java index d202e2a19..e2ff927fe 100644 --- a/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java +++ b/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java @@ -70,7 +70,9 @@ static String appendStoredInlineMarker(String input, String type, String argumen return input; } String marker = type.trim() + ":::" + argumentText.strip(); - if (input.contains(marker)) { + // The same marker may already sit on an earlier branch. Only an end + // marker is already in place; anything else was peeled from the last branch. + if (input.stripTrailing().endsWith(marker)) { return input; } return input + " " + marker; diff --git a/src/main/java/tools/dscode/testengine/ExampleRowFilter.java b/src/main/java/tools/dscode/testengine/ExampleRowFilter.java index 3a66ea3b7..b78d73152 100644 --- a/src/main/java/tools/dscode/testengine/ExampleRowFilter.java +++ b/src/main/java/tools/dscode/testengine/ExampleRowFilter.java @@ -184,7 +184,9 @@ private static boolean passesPriorFilters(Pickle pickle, String tags, String nam } } if (name != null && !name.isBlank()) { - if (!Pattern.compile(name.trim()).matcher(pickle.getName()).matches()) { + // Cucumber's name predicate uses find(), so a partial regex such as + // "outline" selects "Long outline". Example rows run after that selection. + if (!Pattern.compile(name.trim()).matcher(pickle.getName()).find()) { return false; } } diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index 80833de74..08e2eb665 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -29,7 +29,7 @@ The Workbench window is a view of a run you already have. `open-window --run-id= `hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. -Do not treat resolve-runvars as the environment Discover will use. +Do not treat resolve-runvars as the environment Discover will use. That preview is the launcher JVM, not the Discover worker. Trust the run record after Discover. Do not copy consumer features into `.pickleball` as a sandbox. 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 e6ecb8f25..6fc71e728 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 @@ -22,7 +22,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation | `nested-and-block-conditionals.feature` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/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 3568bf91a..296e28d91 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 @@ -403,11 +403,13 @@ This inventory helps coding agents discover relevant files. It does not replace ## Framework tests +- `src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java` - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` +- `src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java` - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballArtifactLocatorTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index 1b42d9eeb..e39388551 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -370,7 +370,7 @@ PKB_props.overrideRunVars(Map.of( )); ``` -Dry-run resolve does **not** start tests or browsers: +Dry-run resolve does **not** start tests or browsers. `DiagnosticCli resolve-runvars` and Workbench `hint` preview the launcher JVM, not the Discover worker. Do not treat that preview, including its `pkb_environment`, as the environment Discover will use. Trust the run record after Discover. ```java PKB_props.ResolvedRunVars preview = PKB_props.resolveRunVars(values); diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md b/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md index 1da9181d7..514513c16 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md @@ -71,6 +71,14 @@ Use `THEN:` when the result fits on one line: Branches are considered from left to right. Only the first matching branch runs. +## Data tables and doc strings + +An `IF:` / `THEN:` / `ELSE-IF:` / `ELSE:` branch that runs as its own step inherits the step's data table or doc string. A `THEN:` or `ELSE:` action that does not already start with a comma is run that way. A comma action stays in the IF step and uses that same table or doc string. + +A branch's own `DT:::...|` inline table replaces the inherited argument for that branch only. Any other inline type keeps the inherited argument. + +A marker peeled off the end of the whole line is put back on the last branch only. Earlier branches keep the step's data table or doc string. That still happens when an earlier branch already contains the same marker text. + ## Multi-step branches End the branch with a colon and place its work beneath it: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index ddc791b7c..cf3e6b4e8 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -284,7 +284,7 @@ Dry-run without starting tests: PKB_props.ResolvedRunVars preview = PKB_props.resolveRunVars(values); ``` -`DiagnosticCli resolve-runvars` and Workbench `hint` print that preview, including per-key provenance (`override`, `runvars`, `profile`, `jvm`, `inherited-context`, `default`, `properties`). Agents resolve → inspect → complete map → `pkb_overriderunvars` → compare fingerprint. +`DiagnosticCli resolve-runvars` and Workbench `hint` print that preview, including per-key provenance (`override`, `runvars`, `profile`, `jvm`, `inherited-context`, `default`, `properties`). The preview is the launcher JVM, not the Discover worker. Do not treat its `pkb_environment` as the environment Discover will use. Trust the run record after Discover. Agents resolve → inspect → complete map → `pkb_overriderunvars` → compare fingerprint. Sealed input is `pkb_overriderunvars` only. ### Templates and runtime configs diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index 80833de74..08e2eb665 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -29,7 +29,7 @@ The Workbench window is a view of a run you already have. `open-window --run-id= `hint` (alias `discover-hint`) is `-Dexec.args=hint` and prints the recommended Discover `pkb_runvars`, a dry-run resolve preview that does not start tests, and `NEXT: run discover`. Default Discover/Confirm stay on `pkb_runvars`. Sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map (six context keys required) → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. Do not mix sealed input with `pkb_runvars` or `pkb_profile`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. If `workbench_*` tools already exist they are the same session, not a setup step. -Do not treat resolve-runvars as the environment Discover will use. +Do not treat resolve-runvars as the environment Discover will use. That preview is the launcher JVM, not the Discover worker. Trust the run record after Discover. Do not copy consumer features into `.pickleball` as a sandbox. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index d590c5779..65530dc4d 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot 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 198d9d094..9b2c0fed4 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 @@ -113,7 +113,7 @@ Build tools stay the project wrapper. Discover, confirm, and `session-sync` invo `hint`, `discover`, and `confirm` accept `--tags` / `--name` / `--example` plus `--retention=all|failed|none` (or `--retention `). Discover and hint default to `failed`. Confirm without `--retention` keeps the Discover snapshot value. This retention overlay is launcher/planner-only; do not pass `--retention` to the Workbench controller `isolate` / `session` parser. Ordinary diagnostic runs still default to `pkb_reportretention=all`. -Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` print a dry-run resolve preview (`PKB_props.resolveRunVars`) that does not start tests. Optional sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map including the six context keys → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. When a LastDiscoverSnapshot is marked sealed, the next worker launch uses `-Dpkb_overriderunvars=`, never `-Dpkb_run_profile=`. Unused, sealed input changes no behavior. +Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` print a dry-run resolve preview of the launcher JVM, not the Discover worker (`PKB_props.resolveRunVars`). It does not start tests. Do not treat that preview, including its `pkb_environment`, as the environment Discover will use. Trust the run record after Discover. Optional sealed `pkb_overriderunvars` is opt-in: resolve → inspect → complete map including the six context keys → `-Dpkb_overriderunvars=` → compare `runProfileFingerprint`. When a LastDiscoverSnapshot is marked sealed, the next worker launch uses `-Dpkb_overriderunvars=`, never `-Dpkb_run_profile=`. Unused, sealed input changes no behavior. `mcp` and `ui` remain host/human commands. Hosts that already run Workbench MCP can launch it from the consumer test classpath: diff --git a/src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java b/src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java index db70f3299..313f57c5c 100644 --- a/src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java +++ b/src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java @@ -22,7 +22,11 @@ void wrapperPrefersTheOsScriptThenTheWrapperJar() throws Exception { Path script = project.resolve(windows ? "mvnw.cmd" : "mvnw"); Files.writeString(script, windows ? "@echo off\r\n" : "#!/bin/sh\n"); List command = ConsumerMavenTestRunner.command(project, "pkb_tags=@one"); - assertEquals(script.toString(), command.getFirst()); + if (windows) { + assertEquals(List.of("cmd.exe", "/d", "/c", script.toString()), command.subList(0, 4)); + } else { + assertEquals(script.toString(), command.getFirst()); + } assertTrue(command.contains("test")); Files.delete(script); @@ -41,4 +45,41 @@ void wrapperPrefersTheOsScriptThenTheWrapperJar() throws Exception { assertTrue(failure.getMessage().contains("mvnw")); assertTrue(failure.getMessage().contains("machine-wide")); } + + @Test + void gradleWrapperIsUsedForAGradleProjectAndTheJarWhenTheScriptIsAbsent() throws Exception { + boolean windows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + Files.writeString(project.resolve("build.gradle"), "plugins { id 'java' }\n"); + Path jar = project.resolve("gradle/wrapper/gradle-wrapper.jar"); + Files.createDirectories(jar.getParent()); + Files.writeString(jar, "jar"); + + List command = ConsumerMavenTestRunner.command(project, "pkb_tags=@one"); + assertEquals("-jar", command.get(1)); + assertEquals(jar.toString(), command.get(2)); + assertTrue(command.contains("test")); + assertTrue(command.stream().anyMatch(item -> item.startsWith("-Dpkb_runvars="))); + + Path script = project.resolve(windows ? "gradlew.bat" : "gradlew"); + Files.writeString(script, windows ? "@echo off\r\n" : "#!/bin/sh\n"); + List scriptCommand = ConsumerMavenTestRunner.confirmCommand(project, "pkb_tags=@one"); + if (windows) { + assertEquals(List.of("cmd.exe", "/d", "/c", script.toString()), scriptCommand.subList(0, 4)); + } else { + assertEquals(script.toString(), scriptCommand.getFirst()); + } + assertTrue(scriptCommand.contains("-Dpkb_run_purpose=workbench-confirm")); + } + + @Test + void aDirectoryThatIsBothMavenAndGradleIsRejected() throws Exception { + Files.writeString(project.resolve("pom.xml"), ""); + Files.writeString(project.resolve("build.gradle"), "plugins { id 'java' }\n"); + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> ConsumerMavenTestRunner.wrapper(project) + ); + assertTrue(failure.getMessage().contains("one Maven or Gradle")); + assertTrue(failure.getMessage().contains("machine-wide")); + } } diff --git a/src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java b/src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java index bd7e6be99..e99729b51 100644 --- a/src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java +++ b/src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java @@ -76,6 +76,18 @@ void eachBranchKeepsItsOwnInlineTableAndOtherwiseInheritsTheStepArgument() { assertEquals(DEFAULT_TABLE, ParsedLine.gherkinArgumentForBranch("enter the note NOTE:::hello|", DEFAULT_TABLE)); } + @Test + void elseIfThenAndElseInheritOrOverrideOnTheirOwn() { + String rewritten = ParsedLine.rewriteConditionalBranches( + "IF: ready THEN: enter one ELSE-IF: later THEN: enter two DT:::city|Paris| ELSE: enter three"); + List branches = backtickValues(rewritten); + assertEquals(3, branches.size()); + assertEquals(DEFAULT_TABLE, ParsedLine.gherkinArgumentForBranch(branches.get(0), DEFAULT_TABLE)); + assertTrue(ParsedLine.gherkinArgumentForBranch(branches.get(1), DEFAULT_TABLE).contains("Paris")); + assertFalse(ParsedLine.gherkinArgumentForBranch(branches.get(1), DEFAULT_TABLE).contains("Tempe")); + assertEquals(DEFAULT_DOC, ParsedLine.gherkinArgumentForBranch(branches.get(2), DEFAULT_DOC)); + } + @Test void peeledMarkerIsRestoredOntoTheLastBranchOnly() { String restored = ParsedLine.appendStoredInlineMarker( @@ -93,6 +105,17 @@ void peeledMarkerIsRestoredOntoTheLastBranchOnly() { assertFalse(first.contains("Paris")); assertTrue(last.contains("Paris")); assertFalse(last.contains("Tempe")); + + String duplicated = ParsedLine.appendStoredInlineMarker( + "IF: ready THEN: enter one DT:::city|Paris| ELSE: enter two", + "DT", + "city|Paris|"); + assertTrue(duplicated.stripTrailing().endsWith("ELSE: enter two DT:::city|Paris|")); + List both = backtickValues(ParsedLine.rewriteConditionalBranches(duplicated)); + assertEquals(2, both.size()); + assertTrue(both.get(0).contains("DT:::city|Paris|")); + assertTrue(both.get(1).contains("DT:::city|Paris|")); + assertEquals(1, both.get(1).split("DT:::", -1).length - 1); } private static void assertExtracts(String text) { diff --git a/src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java b/src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java index 4abb816a2..243a8df20 100644 --- a/src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java +++ b/src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java @@ -286,6 +286,31 @@ void orderLimitStillAppliesAfterTheExampleFilter() { assertEquals(List.of("a"), cells(CucumberScanUtil.listPickles(props))); } + @Test + void partialNameRegexRunsBeforeTheExampleFilter() { + assertEquals( + List.of( + "First outline:a", + "Short outline:a", + "Long outline:a", + "Tagged outline:a" + ), + named(scan(featureFile.toString(), "1", null, "outline", null)) + ); + } + + @Test + void discoveryAppliesExampleAfterAPartialNameRegex() throws IOException { + Feature feature = readFeature(featureFile); + TestDescriptor root = suite(feature); + ExampleRowFilter.applyToDiscoveredSuite(root, "1", null, "outline"); + Map counts = counts(root); + assertEquals(1, counts.getOrDefault("plain", 0)); + assertEquals(4, counts.getOrDefault("a", 0)); + assertEquals(0, counts.getOrDefault("b", 0)); + assertEquals(0, counts.getOrDefault("e", 0)); + } + @Test void discoveryDropsSelectedRowsThatMissTheExample() throws IOException { Feature feature = readFeature(featureFile); From decc8d7a4c352ecd8ea9c0df8fd1f48f1e3fd23f Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Fri, 2 Oct 2026 03:22:08 -0700 Subject: [PATCH 14/23] Bottom line: a RUN row runs only when RunIf is true, called backgrounds run only when RunBackground is true, and a partial scenario name keeps example row 2. --- docs/agent/feature-map.md | 8 +- docs/agent/repository-index.md | 7 + docs/component-scenarios.md | 20 +- docs/configuration.md | 2 +- docs/service-call-scenarios.md | 4 + .../example/pickleball/SyntaxProofSteps.java | 126 ++++++++++ .../features/example-row-proof.feature | 13 + .../resources/features/run-row-gates.feature | 39 +++ .../features/syntax-since-2.1.14.feature | 235 ++++++++++++++++++ ...tepDefinitionMatchArgumentPaddingAspect.aj | 29 +++ .../core/runner/CalledFeatureBackground.java | 89 +++++++ .../io/cucumber/core/runner/ScenarioStep.java | 15 +- .../cucumber/core/runner/StepExtension.java | 6 +- .../common/assertions/AssertionChain.java | 4 +- .../treeparsing/parsedComponents/Phrase.java | 4 +- .../phraseoperations/AssertionOperations.java | 21 +- .../coredefinitions/ModularScenarios.java | 30 ++- .../coredefinitions/RunRowCondition.java | 115 +++++++++ .../dscode/testengine/PickleballRunner.java | 23 ++ .../guidance/docs/agent/feature-map.md | 8 +- .../guidance/docs/agent/repository-index.md | 7 + .../guidance/docs/component-scenarios.md | 20 +- .../pickleball/guidance/docs/configuration.md | 2 +- .../guidance/docs/service-call-scenarios.md | 4 + .../META-INF/pickleball/guidance/index.txt | 3 + .../testengine/CucumberNameFilterTest.java | 27 ++ 26 files changed, 838 insertions(+), 23 deletions(-) create mode 100644 maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java create mode 100644 maven-consumer-project/src/test/resources/features/example-row-proof.feature create mode 100644 maven-consumer-project/src/test/resources/features/run-row-gates.feature create mode 100644 maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature create mode 100644 src/main/java/io/cucumber/core/runner/CalledFeatureBackground.java create mode 100644 src/main/java/tools/dscode/coredefinitions/RunRowCondition.java create mode 100644 src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 6fc71e728..85c7702ae 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -18,14 +18,14 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | -| Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | -| Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | +| Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `RunRowCondition.java`; `CalledFeatureBackground.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection`, `RunIf`, `RunBackground` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; `run-row-gates.feature`; `SyntaxProofSteps.java`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | +| Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | | Date/time utilities | `DateTimeUtilitySteps.java`; `common/util/datetime` | date/time consumer feature and Java checks | `docs/date-time-utilities.md` | | Dialog handling | `BrowserAlerts.java`; browser definitions | `dialogs.feature` | dynamic-step/custom-element docs | | Cucumber compatibility/AspectJ weaving | `src/main/aspectj/io/cucumber`; `src/main/java/io/cucumber`; `build.gradle` | consumer runner and full feature suite | `docs/cucumber-compatibility.md`; `docs/feature-status-notes.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 296e28d91..c5ac77b07 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -91,6 +91,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/io/cucumber/core/gherkin/messages/MessageUtilities.java` - `src/main/java/io/cucumber/core/gherkin/messages/NGherkinFactory.java` - `src/main/java/io/cucumber/core/runner/ArgStepFunctions.java` +- `src/main/java/io/cucumber/core/runner/CalledFeatureBackground.java` - `src/main/java/io/cucumber/core/runner/CoreConstants.java` - `src/main/java/io/cucumber/core/runner/CucumberStepInvoker.java` - `src/main/java/io/cucumber/core/runner/CurrentScenarioState.java` @@ -369,6 +370,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/coredefinitions/NavigationSteps.java` - `src/main/java/tools/dscode/coredefinitions/ObjectRegistrationSteps.java` - `src/main/java/tools/dscode/coredefinitions/ReportingSteps.java` +- `src/main/java/tools/dscode/coredefinitions/RunRowCondition.java` - `src/main/java/tools/dscode/coredefinitions/ServiceCallSteps.java` - `src/main/java/tools/dscode/coredefinitions/TableSteps.java` - `src/main/java/tools/dscode/coredefinitions/UtilitySteps.java` @@ -421,6 +423,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java` - `src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorTest.java` +- `src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` - `src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java` @@ -656,6 +659,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/com/example/pickleball/StepOverrideChecks.java` - `maven-consumer-project/src/test/java/com/example/pickleball/support/InternalJavaTestRunner.java` - `maven-consumer-project/src/test/java/com/example/pickleball/support/LocalTestSite.java` +- `maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java` - `maven-consumer-project/src/test/java/com/example/pickleball/tests/TokenizedQueryTest.java` - `maven-consumer-project/src/test/java/io/cucumber/core/runner/ScenarioStepChecks.java` - `maven-consumer-project/src/test/java/io/cucumber/core/runner/ScenarioStepDataChecks.java` @@ -702,6 +706,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/features/diagnostic-reporting-validation.feature` - `maven-consumer-project/src/test/resources/features/dialogs.feature` - `maven-consumer-project/src/test/resources/features/dynamic-steps.feature` +- `maven-consumer-project/src/test/resources/features/example-row-proof.feature` - `maven-consumer-project/src/test/resources/features/forms-dynamic-steps.feature` - `maven-consumer-project/src/test/resources/features/internal-framework-java-checks.feature` - `maven-consumer-project/src/test/resources/features/it-placeholder.feature` @@ -713,12 +718,14 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/features/nested-and-block-conditionals.feature` - `maven-consumer-project/src/test/resources/features/pickleball-2.1.2-syntax-demo.feature` - `maven-consumer-project/src/test/resources/features/reusable-scenario-selection.feature` +- `maven-consumer-project/src/test/resources/features/run-row-gates.feature` - `maven-consumer-project/src/test/resources/features/run-step-parameter-variations.feature` - `maven-consumer-project/src/test/resources/features/scenario-data-references.feature` - `maven-consumer-project/src/test/resources/features/scenario-marker-data.feature` - `maven-consumer-project/src/test/resources/features/scenario-step-markers.feature` - `maven-consumer-project/src/test/resources/features/service-call-execution.feature` - `maven-consumer-project/src/test/resources/features/step-override-bridge.feature` +- `maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature` ## Maven consumer service-call definitions diff --git a/docs/component-scenarios.md b/docs/component-scenarios.md index 83ef28ecb..84a731e3b 100644 --- a/docs/component-scenarios.md +++ b/docs/component-scenarios.md @@ -1,6 +1,6 @@ # Component Scenarios -> **Working feature examples:** [`component-scenarios.feature`](../maven-consumer-project/src/test/resources/features/component-scenarios.feature) contains a reusable component. [`reusable-scenario-selection.feature`](../maven-consumer-project/src/test/resources/features/reusable-scenario-selection.feature) demonstrates named selectors, escaped names, ordering, limits, and singular/plural behavior. [`run-step-parameter-variations.feature`](../maven-consumer-project/src/test/resources/features/run-step-parameter-variations.feature) demonstrates the canonical table-driven `RUN` form, mixed run types, and shorthand variations. [`scenario-data-references.feature`](../maven-consumer-project/src/test/resources/features/scenario-data-references.feature) demonstrates marker and data-file references. +> **Working feature examples:** [`component-scenarios.feature`](../maven-consumer-project/src/test/resources/features/component-scenarios.feature) contains a reusable component. [`reusable-scenario-selection.feature`](../maven-consumer-project/src/test/resources/features/reusable-scenario-selection.feature) demonstrates named selectors, escaped names, ordering, limits, and singular/plural behavior. [`run-step-parameter-variations.feature`](../maven-consumer-project/src/test/resources/features/run-step-parameter-variations.feature) demonstrates the canonical table-driven `RUN` form, mixed run types, and shorthand variations. [`syntax-since-2.1.14.feature`](../maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature) demonstrates `RunIf`, `RunBackground`, and inline `DT:::` inheritance. [`scenario-data-references.feature`](../maven-consumer-project/src/test/resources/features/scenario-data-references.feature) demonstrates marker and data-file references. Component scenarios are reusable, scenario-sized flows. `RUN` is the common dispatcher for regular scenarios, component scenarios, and service calls. @@ -110,12 +110,14 @@ Blank components and more than three unescaped components are rejected. ## Invocation options -Every invocation-table column is passed to the scenario scan. Common options include: +Every invocation-table column except the reserved `RunIf` and `RunBackground` columns is passed to the scenario scan. Those two columns are not search filters and are not merged into the called scenario. Common options include: | Purpose | Pickleball option | Cucumber option | |---|---|---| | Run kind/cardinality | `RunType` | — | | Saved result key | `RunKey` | — | +| Row gate | `RunIf` | — | +| Called-feature background | `RunBackground` | — | | Component feature path | `pkb_componentpath` | — | | Exact feature name | `pkb_featurename` | — | | Scenario-name regex | `pkb_name` | `cucumber.filter.name` | @@ -124,6 +126,20 @@ Every invocation-table column is passed to the scenario scan. Common options inc | Result limit | `pkb_limit` | `cucumber.execution.limit` | | Start marker | `Step_Marker` | — | +### RunIf and RunBackground + +`RunIf` is evaluated in the calling scenario before that row is searched or started. The cell uses the same evaluator as the text after `IF:` in a block conditional. True runs the row. False skips it. A skipped row does not fail `RUN`, does not start its called scenario, and later rows are still evaluated. When one row matches several scenarios, that whole match runs or skips together. + +A blank cell, the word `null`, or a reference that does not resolve is false. Explicit `true` and `false` values work. Dynamic assertions such as ` has value` and block comparisons such as `4 > 3` work. When the `RunIf` column is absent, every row runs. + +`RunBackground` uses that same evaluation. Background steps in the called feature run only when the cell is true. A blank, null, or unresolved cell does not run them. When the `RunBackground` column is absent, `RUN` ignores called-feature backgrounds. That is the default for every `RUN` data table, including scenario, component-scenario, and service-call rows. `SCENARIO:`, `COMPONENT:`, `CALL:`, and a root scenario keep their existing background behavior. + +```gherkin +* RUN + | RunType | RunKey | RunIf | RunBackground | + | SCENARIO | setup | has value | true | +``` + ```gherkin * RUN | RunType | pkb_featurename | pkb_name | pkb_order | diff --git a/docs/configuration.md b/docs/configuration.md index cf3e6b4e8..03a6a51f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -401,7 +401,7 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, ## Example row filter -`pkb_example` keeps specific Examples rows from scenarios that `pkb_tags`, `pkb_name`, and, on a component or `listPickles` scan, `pkb_featurename` have already selected. Where those filters select scenarios, `pkb_example` runs after them. It is not a tag and it does not extend Cucumber tag expressions (`and` / `or` / `not`). It has no `cucumber.filter` alias. `pkb_name` still matches the Scenario or Scenario Outline title and still selects every row until `pkb_example` runs. A normal Maven run applies the same filter after Cucumber's tag and name filters. It does not start honoring `pkb_featurename` on that engine path. +`pkb_example` keeps specific Examples rows from scenarios that `pkb_tags`, `pkb_name`, and, on a component or `listPickles` scan, `pkb_featurename` have already selected. Where those filters select scenarios, `pkb_example` runs after them. It is not a tag and it does not extend Cucumber tag expressions (`and` / `or` / `not`). It has no `cucumber.filter` alias. `pkb_name` is a search of the Scenario or Scenario Outline title, the same rule Cucumber uses when it discovers scenarios. `Partial name` selects `Partial name example rows`. It still selects every row of that scenario until `pkb_example` runs. The Maven run keeps a scenario that this search selected. A normal Maven run applies the same filter after Cucumber's tag and name filters. It does not start honoring `pkb_featurename` on that engine path. Absent or blank `pkb_example` adds no filter. diff --git a/docs/service-call-scenarios.md b/docs/service-call-scenarios.md index e4af6cec7..98d922998 100644 --- a/docs/service-call-scenarios.md +++ b/docs/service-call-scenarios.md @@ -135,6 +135,8 @@ Both store explicit `RETURN` when present and otherwise store the completed scen |---|---|---| | Run kind/cardinality | `RunType` | — | | Saved result key | `RunKey` | — | +| Row gate | `RunIf` | — | +| Called-feature background | `RunBackground` | — | | Call feature path | `pkb_callpath` | — | | Exact feature name | `pkb_featurename` | — | | Scenario-name regex | `pkb_name` | `cucumber.filter.name` | @@ -143,6 +145,8 @@ Both store explicit `RETURN` when present and otherwise store the completed scen | Result limit | `pkb_limit` | `cucumber.execution.limit` | | Start marker | `Step_Marker` | — | +`RunIf` and `RunBackground` are reserved on this same `RUN` table. They are not search filters and they are not passed into the called scenario. `RunIf` is evaluated in the caller, before the row starts, with the same rules as the text after `IF:` in a block conditional. False skips the row without failing `RUN`. `RunBackground` true runs background steps in the called feature. Blank, null, and unresolved cells are false for both columns. When `RunIf` is absent, every row runs. When `RunBackground` is absent, `RUN` ignores called-feature backgrounds. That default applies to every `RUN` row, including `SERVICE CALL`. `CALL:` is unchanged. + Inline feature/scenario/marker components overwrite their equivalent table selector fields. A nonblank table `RunType` overrides the inline run type, and `RunKey` keeps its precedence over a quoted key. ## Singular and plural behavior diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java new file mode 100644 index 000000000..1e4d76007 --- /dev/null +++ b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java @@ -0,0 +1,126 @@ +package com.example.pickleball; + +import io.cucumber.core.runner.GlobalState; +import io.cucumber.core.runner.StepExtension; +import io.cucumber.datatable.DataTable; +import io.cucumber.docstring.DocString; +import io.cucumber.java.en.Given; +import tools.dscode.common.mappings.ParsingMap; +import tools.dscode.launcher.WorkbenchAgentCommands; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static tools.dscode.common.mappings.MappingProcessor.getRunMap; + +public class SyntaxProofSteps { + @Given("^the recorded city is saved as \"([^\"]*)\"$") + public void recordCity(String key, DataTable table) { + getRunMap().put(key, cityCell(table)); + getRunMap().put(key + "Marker", inlineMarker()); + } + + @Given("^the recorded note is saved as \"([^\"]*)\"$") + public void recordNote(String key, DocString docString) { + String content = docString == null || docString.getContent() == null + ? "" + : docString.getContent().trim(); + getRunMap().put(key, content); + getRunMap().put(key + "Marker", inlineMarker()); + } + + @Given("^the called row \"([^\"]*)\" records its gates$") + public void recordGates(String token) { + ParsingMap map = ParsingMap.getRunningParsingMap(); + getRunMap().put(token + "RunIf", presence(map.get("RunIf"))); + getRunMap().put(token + "RunBackground", presence(map.get("RunBackground"))); + Object mark = map.get(token); + getRunMap().put( + token + "Mark", + "B".equals(String.valueOf(mark)) ? "with-background" : "without-background" + ); + } + + @Given("^resolve-runvars reports the launcher JVM$") + public void resolveRunVarsReportsLauncherJvm() { + Path project = consumerProject(); + ByteArrayOutputStream outBytes = new ByteArrayOutputStream(); + ByteArrayOutputStream errBytes = new ByteArrayOutputStream(); + int exit = WorkbenchAgentCommands.run( + new String[]{"resolve-runvars", project.toString()}, + new PrintStream(outBytes, true, StandardCharsets.UTF_8), + new PrintStream(errBytes, true, StandardCharsets.UTF_8) + ); + String out = outBytes.toString(StandardCharsets.UTF_8); + if (exit != 0) { + throw new AssertionError("resolve-runvars exited " + exit + ": " + errBytes.toString(StandardCharsets.UTF_8)); + } + require(out, "launcher JVM"); + require(out, "not the Discover worker"); + require(out, "Do not treat resolve-runvars as the environment Discover will use"); + require(out, "does not start tests"); + if (out.contains("NEXT: run discover")) { + throw new AssertionError("resolve-runvars followed the Discover hint path"); + } + } + + private static String cityCell(DataTable table) { + List> cells = table.cells(); + if (cells.isEmpty() || cells.getFirst().isEmpty()) { + return ""; + } + if (cells.size() == 1) { + List row = cells.getFirst(); + return row.size() > 1 ? row.get(1) : row.getFirst(); + } + List row = cells.get(1); + return row.isEmpty() ? "" : row.getFirst(); + } + + private static String inlineMarker() { + StepExtension step = GlobalState.getRunningStep(); + if (step == null || step.getInlineArgumentType() == null) { + return ""; + } + return step.getInlineArgumentType(); + } + + private static String presence(Object value) { + return value == null ? "hidden" : "leaked:" + value; + } + + private static void require(String text, String expected) { + if (!text.contains(expected)) { + throw new AssertionError("resolve-runvars output missing '" + expected + "': " + text); + } + } + + private static Path consumerProject() { + Path start = Path.of(System.getProperty("user.dir", ".")).toAbsolutePath().normalize(); + Path dir = start; + for (int i = 0; i < 8 && dir != null; i++) { + Path direct = candidate(dir); + if (direct != null) { + return direct; + } + Path nested = candidate(dir.resolve("maven-consumer-project")); + if (nested != null) { + return nested; + } + dir = dir.getParent(); + } + throw new IllegalStateException("Could not find the consumer project from " + start); + } + + private static Path candidate(Path dir) { + if (Files.isRegularFile(dir.resolve("pom.xml")) + && Files.isDirectory(dir.resolve("src/test/resources/features"))) { + return dir; + } + return null; + } +} diff --git a/maven-consumer-project/src/test/resources/features/example-row-proof.feature b/maven-consumer-project/src/test/resources/features/example-row-proof.feature new file mode 100644 index 000000000..530a2f26a --- /dev/null +++ b/maven-consumer-project/src/test/resources/features/example-row-proof.feature @@ -0,0 +1,13 @@ +@example-row-proof +Feature: Partial scenario name keeps one example row + + Scenario Outline: Partial name example rows + * , verify "" equals "2" + Examples: + | row | + | 1 | + | 2 | + | 3 | + + Scenario: Partial name plain scenario + * FAIL: plain scenario should not run when example row 2 is selected diff --git a/maven-consumer-project/src/test/resources/features/run-row-gates.feature b/maven-consumer-project/src/test/resources/features/run-row-gates.feature new file mode 100644 index 000000000..be48d1061 --- /dev/null +++ b/maven-consumer-project/src/test/resources/features/run-row-gates.feature @@ -0,0 +1,39 @@ +Feature: Called rows for RunIf and RunBackground + + Background: + * , save "B" as "" + + @run-row-visit + Scenario: Run row child visit + * the called row "" records its gates + + @run-row-bomb + Scenario: Run row child bomb + * FAIL: this row should have been skipped + + @run-row-multi + Scenario: Run row multi bomb alpha + * FAIL: multi match alpha should have been skipped + + @run-row-multi + Scenario: Run row multi bomb beta + * FAIL: multi match beta should have been skipped + + @run-row-multi-ok + Scenario: Run row multi ok alpha + * , save "alpha" as "multiAlpha" + * the called row "" records its gates + + @run-row-multi-ok + Scenario: Run row multi ok beta + * , save "beta" as "multiBeta" + * the called row "" records its gates + + Scenario Outline: Scan partial name rows + * , save "" as "exampleRow" + * , verify "" equals "2" + Examples: + | row | + | 1 | + | 2 | + | 3 | diff --git a/maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature b/maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature new file mode 100644 index 000000000..f2e318069 --- /dev/null +++ b/maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature @@ -0,0 +1,235 @@ +@all @syntax-since-2.1.14 +Feature: Syntax added since 2.1.14 + + Scenario: A normal step inherits its data table + * the recorded city is saved as "inheritedCity" + | city | + | Tempe | + * , verify "" equals "Tempe" + * , verify "" equals "" + + Scenario: A DT marker overrides the step data table + * the recorded city is saved as "overrideCity" DT:::city|Paris| + | city | + | Tempe | + * , verify "" equals "Paris" + * , verify "" equals "DT" + + Scenario: An IF branch inherits the line data table + * IF: 1 == 1 THEN: the recorded city is saved as "ifCity" ELSE-IF: 1 == 0 THEN: the recorded city is saved as "ifElseIfMiss" ELSE: the recorded city is saved as "ifElseMiss" + | city | + | Tempe | + * , verify "" equals "Tempe" + + Scenario: An ELSE-IF branch inherits the line data table + * IF: 1 == 0 THEN: the recorded city is saved as "elseIfMiss" ELSE-IF: 1 == 1 THEN: the recorded city is saved as "elseIfCity" ELSE: the recorded city is saved as "elseIfElseMiss" + | city | + | Tempe | + * , verify "" equals "Tempe" + + Scenario: An ELSE branch inherits the line data table + * IF: 1 == 0 THEN: the recorded city is saved as "elseThenMiss" ELSE-IF: 1 == 0 THEN: the recorded city is saved as "elseElseIfMiss" ELSE: the recorded city is saved as "elseCity" + | city | + | Tempe | + * , verify "" equals "Tempe" + + Scenario: An IF branch inherits the line doc string + * IF: 1 == 1 THEN: the recorded note is saved as "ifNote" ELSE-IF: 1 == 0 THEN: the recorded note is saved as "ifNoteElseIf" ELSE: the recorded note is saved as "ifNoteElse" + """ + hello from the if line + """ + * , verify "" equals "hello from the if line" + * , verify "" equals "" + + Scenario: An ELSE-IF branch inherits the line doc string + * IF: 1 == 0 THEN: the recorded note is saved as "elseIfNoteMiss" ELSE-IF: 1 == 1 THEN: the recorded note is saved as "elseIfNote" ELSE: the recorded note is saved as "elseIfNoteElse" + """ + hello from the else if line + """ + * , verify "" equals "hello from the else if line" + + Scenario: An ELSE branch inherits the line doc string + * IF: 1 == 0 THEN: the recorded note is saved as "elseNoteMiss" ELSE-IF: 1 == 0 THEN: the recorded note is saved as "elseNoteElseIf" ELSE: the recorded note is saved as "elseNote" + """ + hello from the else line + """ + * , verify "" equals "hello from the else line" + + Scenario: A branch DT marker overrides only that branch + * IF: 1 == 1 THEN: the recorded city is saved as "branchOverride" DT:::city|Paris| ELSE: the recorded city is saved as "branchSkipped" + | city | + | Tempe | + * , verify "" equals "Paris" + + Scenario: A branch without DT keeps the inherited table + * IF: 1 == 0 THEN: the recorded city is saved as "branchSkippedTable" DT:::city|Paris| ELSE: the recorded city is saved as "branchKept" + | city | + | Tempe | + * , verify "" equals "Tempe" + + Scenario: A peeled marker stays off the earlier identical branch + * IF: 1 == 1 THEN: the recorded city is saved as "peeledFirst" ELSE: the recorded city is saved as "peeledFirst" DT:::city|Paris| + | city | + | Tempe | + * , verify "" equals "Tempe" + + Scenario: A peeled marker returns on the last identical branch + * IF: 1 == 0 THEN: the recorded city is saved as "peeledLast" ELSE: the recorded city is saved as "peeledLast" DT:::city|Paris| + | city | + | Tempe | + * , verify "" equals "Paris" + + Scenario: An earlier branch keeps its own DT marker + * IF: 1 == 1 THEN: the recorded city is saved as "earlyOwn" DT:::city|London| ELSE: the recorded city is saved as "earlyOwnLast" DT:::city|Paris| + | city | + | Tempe | + * , verify "" equals "London" + + Scenario: The last branch still receives the peeled marker + * IF: 1 == 0 THEN: the recorded city is saved as "lateOwn" DT:::city|London| ELSE: the recorded city is saved as "latePeeled" DT:::city|Paris| + | city | + | Tempe | + * , verify "" equals "Paris" + + Scenario: Nested DT steps under a block conditional + * IF: 1 == 1: + : * the recorded city is saved as "nestedBlockInherit" + | city | + | Tempe | + : * the recorded city is saved as "nestedBlock" DT:::city|Paris| + | city | + | Tempe | + * , verify "" equals "Tempe" + * , verify "" equals "Paris" + * , verify "" equals "DT" + + Scenario: Nested DT steps under a regular conditional + * , if 1 == 1: + : * the recorded city is saved as "nestedCommaInherit" + | city | + | Tempe | + : * the recorded city is saved as "nestedComma" DT:::city|Paris| + | city | + | Tempe | + * , verify "" equals "Tempe" + * , verify "" equals "Paris" + * , verify "" equals "DT" + + Scenario: Another inline type stays stored and does not become a table + * the recorded note is saved as "noted" NOTE:::hello| + """ + kept doc string + """ + * , verify "" equals "kept doc string" + * , verify "" equals "NOTE" + + Scenario: RunIf skips false rows and later rows still run + * , save "hello" as "A" + * , save "" as "EmptyA" + * RUN + | RunType | pkb_tags | token | RunIf | RunBackground | + | SCENARIO | @run-row-bomb | falseRow | false | true | + | SCENARIO | @run-row-bomb | blankRow | | true | + | SCENARIO | @run-row-bomb | nullRow | null | true | + | SCENARIO | @run-row-bomb | missingRow | | true | + | SCENARIO | @run-row-bomb | boolFalse | FALSE | true | + | SCENARIO | @run-row-bomb | emptyRow | has value | true | + | SCENARIO | @run-row-bomb | ltRow | 4 > 5 | true | + | SCENARIOS | @run-row-multi | multiSkip | false | true | + | SCENARIO | @run-row-visit | trueRow | true | true | + | SCENARIO | @run-row-visit | boolRow | TRUE | false | + | SCENARIO | @run-row-visit | valueRow | has value | true | + | SCENARIO | @run-row-visit | gtRow | 4 > 3 | true | + | SCENARIOS | @run-row-multi-ok | multiRun | true | true | + * , verify "" equals "with-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + * , verify "" equals "without-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + * , verify "" equals "with-background" + * , verify "" equals "hidden" + * , verify "" equals "with-background" + * , verify "" equals "hidden" + * , verify "" equals "alpha" + * , verify "" equals "beta" + * , verify "" equals "with-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + + Scenario: RunBackground uses the same evaluation + * , save "hello" as "A" + * , save "" as "EmptyA" + * RUN + | RunType | pkb_tags | token | RunIf | RunBackground | + | SCENARIO | @run-row-visit | bgTrue | true | true | + | SCENARIO | @run-row-visit | bgFalse | true | false | + | SCENARIO | @run-row-visit | bgBlank | true | | + | SCENARIO | @run-row-visit | bgNull | true | null | + | SCENARIO | @run-row-visit | bgMissing | true | | + | SCENARIO | @run-row-visit | bgBool | true | TRUE | + | SCENARIO | @run-row-visit | bgBoolNo | true | FALSE | + | SCENARIO | @run-row-visit | bgValue | true | has value | + | SCENARIO | @run-row-visit | bgEmpty | true | has value | + | SCENARIO | @run-row-visit | bgGt | true | 4 > 3 | + | SCENARIO | @run-row-visit | bgLt | true | 4 > 5 | + * , verify "" equals "with-background" + * , verify "" equals "without-background" + * , verify "" equals "without-background" + * , verify "" equals "without-background" + * , verify "" equals "without-background" + * , verify "" equals "with-background" + * , verify "" equals "without-background" + * , verify "" equals "with-background" + * , verify "" equals "without-background" + * , verify "" equals "with-background" + * , verify "" equals "without-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + + Scenario: A missing RunIf column runs every row and backgrounds stay off + * RUN + | RunType | pkb_tags | token | + | SCENARIO | @run-row-visit | noColumn | + | SCENARIOS | @run-row-multi-ok | noMulti | + * , verify "" equals "without-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + * , verify "" equals "alpha" + * , verify "" equals "beta" + * , verify "" equals "without-background" + + Scenario: A missing RunBackground column ignores called backgrounds + * RUN + | RunType | pkb_tags | token | RunIf | + | SCENARIO | @run-row-visit | noBg | true | + * , verify "" equals "without-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + + Scenario: Component scenario rows honor RunIf and RunBackground + * RUN + | RunType | pkb_name | pkb_componentpath | token | RunIf | RunBackground | + | COMPONENT SCENARIO | ^Run row child bomb$ | src/test/resources/features | componentSkip | false | true | + | COMPONENT SCENARIO | ^Run row child visit$ | src/test/resources/features | component | true | true | + * , verify "" equals "with-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + + Scenario: Service call rows honor RunIf and RunBackground + * RUN + | RunType | pkb_name | pkb_callpath | token | RunIf | RunBackground | + | SERVICE CALL | ^Run row child bomb$ | src/test/resources/features | serviceSkip | false | true | + | SERVICE CALL | ^Run row child visit$ | src/test/resources/features | service | true | true | + * , verify "" equals "with-background" + * , verify "" equals "hidden" + * , verify "" equals "hidden" + + Scenario: A partial scenario name selects one example row + * RUN + | RunType | pkb_name | pkb_example | + | SCENARIO | Scan partial name | 2 | + * , verify "" equals "2" + + Scenario: resolve-runvars is the launcher JVM + * resolve-runvars reports the launcher JVM diff --git a/src/main/aspectj/io/cucumber/core/runner/PickleStepDefinitionMatchArgumentPaddingAspect.aj b/src/main/aspectj/io/cucumber/core/runner/PickleStepDefinitionMatchArgumentPaddingAspect.aj index 8dada0ead..039c65004 100644 --- a/src/main/aspectj/io/cucumber/core/runner/PickleStepDefinitionMatchArgumentPaddingAspect.aj +++ b/src/main/aspectj/io/cucumber/core/runner/PickleStepDefinitionMatchArgumentPaddingAspect.aj @@ -2,6 +2,8 @@ package io.cucumber.core.runner; import io.cucumber.core.backend.ParameterInfo; import io.cucumber.core.stepexpression.Argument; +import io.cucumber.core.stepexpression.DataTableArgument; +import io.cucumber.core.stepexpression.DocStringArgument; import java.lang.reflect.Array; import java.lang.reflect.Constructor; @@ -37,6 +39,21 @@ public privileged aspect PickleStepDefinitionMatchArgumentPaddingAspect { int argSize = arguments.size(); int paramSize = parameterInfos.size(); + // Cucumber counts a data table or doc string as an argument. An IF line + // keeps that argument for the branch and does not declare it on the glue. + if (argSize > paramSize && blockConditionalGlue(match)) { + int captured = 0; + for (Argument argument : arguments) { + if (!isStepAttachment(argument)) { + captured++; + } + } + if (captured == paramSize) { + arguments.removeIf(PickleStepDefinitionMatchArgumentPaddingAspect::isStepAttachment); + return; + } + } + if (argSize >= paramSize) { return; } @@ -46,6 +63,18 @@ public privileged aspect PickleStepDefinitionMatchArgumentPaddingAspect { } } + private static boolean blockConditionalGlue(PickleStepDefinitionMatch match) { + if (match.stepDefinition == null) { + return false; + } + String location = match.stepDefinition.getLocation(); + return location != null && location.contains("DynamicSteps.executeDynamicStepBlockConditionals"); + } + + private static boolean isStepAttachment(Argument argument) { + return argument instanceof DataTableArgument || argument instanceof DocStringArgument; + } + private static final class PlaceholderArgument implements Argument { private final ParameterInfo parameterInfo; diff --git a/src/main/java/io/cucumber/core/runner/CalledFeatureBackground.java b/src/main/java/io/cucumber/core/runner/CalledFeatureBackground.java new file mode 100644 index 000000000..aff23e535 --- /dev/null +++ b/src/main/java/io/cucumber/core/runner/CalledFeatureBackground.java @@ -0,0 +1,89 @@ +package io.cucumber.core.runner; + +import io.cucumber.core.gherkin.Pickle; +import io.cucumber.core.runner.util.CucumberQueryUtil; +import io.cucumber.messages.types.Background; +import io.cucumber.messages.types.Feature; +import io.cucumber.messages.types.FeatureChild; +import io.cucumber.messages.types.RuleChild; +import io.cucumber.messages.types.Step; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Background steps are identified by Gherkin AST ids. Cached pickles are not mutated. + * An empty id set means the caller must leave the pickle steps unchanged. + */ +final class CalledFeatureBackground { + private static final ConcurrentHashMap> IDS_BY_URI = new ConcurrentHashMap<>(); + + private CalledFeatureBackground() { + } + + static void omitBackgroundSteps(Pickle pickle, List steps) { + Set backgroundIds = backgroundStepIds(pickle); + if (backgroundIds.isEmpty() || steps == null || steps.isEmpty()) { + return; + } + steps.removeIf(step -> isBackgroundStep(step, backgroundIds)); + } + + private static boolean isBackgroundStep(PickleStepTestStep step, Set backgroundStepIds) { + if (step == null || step.getPickleStep() == null || step.getPickleStep().getAstNodeIds() == null) { + return false; + } + for (String id : step.getPickleStep().getAstNodeIds()) { + if (backgroundStepIds.contains(id)) { + return true; + } + } + return false; + } + + private static Set backgroundStepIds(Pickle pickle) { + if (pickle == null || pickle.getUri() == null) { + return Set.of(); + } + String uri = pickle.getUri().toString(); + return IDS_BY_URI.computeIfAbsent(uri, key -> load(pickle)); + } + + private static Set load(Pickle pickle) { + try { + Optional feature = CucumberQueryUtil.featureOf(pickle); + if (feature.isEmpty() || feature.get().getChildren() == null) { + return Set.of(); + } + Set ids = new HashSet<>(); + for (FeatureChild child : feature.get().getChildren()) { + child.getBackground().ifPresent(background -> addSteps(ids, background)); + child.getRule().ifPresent(rule -> { + if (rule.getChildren() == null) { + return; + } + for (RuleChild ruleChild : rule.getChildren()) { + ruleChild.getBackground().ifPresent(background -> addSteps(ids, background)); + } + }); + } + return Set.copyOf(ids); + } catch (RuntimeException exception) { + return Set.of(); + } + } + + private static void addSteps(Set ids, Background background) { + if (background == null || background.getSteps() == null) { + return; + } + for (Step step : background.getSteps()) { + if (step != null && step.getId() != null) { + ids.add(step.getId()); + } + } + } +} diff --git a/src/main/java/io/cucumber/core/runner/ScenarioStep.java b/src/main/java/io/cucumber/core/runner/ScenarioStep.java index 422d3b794..9837af969 100644 --- a/src/main/java/io/cucumber/core/runner/ScenarioStep.java +++ b/src/main/java/io/cucumber/core/runner/ScenarioStep.java @@ -224,6 +224,14 @@ public static ScenarioStep createScenarioStep( Pickle pickle, ParsingMap parsingMap, String startStepMarker + ) { + return createScenarioStep(pickle, parsingMap, startStepMarker, true); + } + public static ScenarioStep createScenarioStep( + Pickle pickle, + ParsingMap parsingMap, + String startStepMarker, + boolean includeBackground ) { io.cucumber.core.runner.TestCase topLevel = GlobalState.getTestCase(); String pickleName = parsingMap == null @@ -249,8 +257,13 @@ public static ScenarioStep createScenarioStep( scenarioStep.stepParsingMap.clear(); scenarioStep.stepParsingMap.getMaps().putAll(parsingMap.getMaps()); } + List pickleSteps = + new ArrayList<>(createPickleStepTestStepsFromPickle(pickle)); + if (!includeBackground) { + CalledFeatureBackground.omitBackgroundSteps(pickle, pickleSteps); + } scenarioStep.initializeScenarioSteps( - createPickleStepTestStepsFromPickle(pickle).stream() + pickleSteps.stream() .map(step -> new StepExtension(getTestCase(), step)) .toList(), parsingMap, diff --git a/src/main/java/io/cucumber/core/runner/StepExtension.java b/src/main/java/io/cucumber/core/runner/StepExtension.java index 7d9fa2840..9bc6972f6 100644 --- a/src/main/java/io/cucumber/core/runner/StepExtension.java +++ b/src/main/java/io/cucumber/core/runner/StepExtension.java @@ -345,13 +345,17 @@ public void insertSteps(List newSteps) { } public StepExtension modifyStepExtension(String newText) { + return modifyStepExtension(newText, getGherkinArgumentText(pickleStepTestStep.getStep())); + } + + public StepExtension modifyStepExtension(String newText, String gherkinArgument) { StepExtension modifiedStep = new StepExtension( testCase, getPickleStepTestStepFromStrings( pickleStepTestStep, pickleStepTestStep.getStep().getKeyword(), newText, - getGherkinArgumentText(pickleStepTestStep.getStep()))); + gherkinArgument == null ? "" : gherkinArgument)); modifiedStep.setStepParsingMap(getStepParsingMap()); modifiedStep.parentStep = parentStep; modifiedStep.nestingLevel = nestingLevel; diff --git a/src/main/java/tools/dscode/common/assertions/AssertionChain.java b/src/main/java/tools/dscode/common/assertions/AssertionChain.java index 666b178b0..586f6ddf3 100644 --- a/src/main/java/tools/dscode/common/assertions/AssertionChain.java +++ b/src/main/java/tools/dscode/common/assertions/AssertionChain.java @@ -134,7 +134,9 @@ public void runChainPhrases() { emitText += nextPhrase.getText() + nextPhrase.termination + " "; lastPhrase = nextPhrase; } - runningStep.childSteps.add(runningStep.modifyStepExtension(emitText)); + runningStep.childSteps.add(runningStep.modifyStepExtension( + emitText, + runningStep.inheritedGherkinArgument())); } } } diff --git a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java index b45f474bb..d9ec6a8e6 100644 --- a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java +++ b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java @@ -77,7 +77,9 @@ boolean shouldRun() { lastPhrase = nextPhrase; } StepExtension runningStep = getRunningStep(); - runningStep.childSteps.add(runningStep.modifyStepExtension(emitText)); + runningStep.childSteps.add(runningStep.modifyStepExtension( + emitText, + runningStep.inheritedGherkinArgument())); setNextPhrase(null); return false; } diff --git a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/AssertionOperations.java b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/AssertionOperations.java index a5cf9f067..6b985576e 100644 --- a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/AssertionOperations.java +++ b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/phraseoperations/AssertionOperations.java @@ -267,12 +267,17 @@ public void execute(PhraseData phraseData) { logInfo(phraseData + " : Executing Assertion " + this.name()); int repetition = phraseData.getRepetition(); - ElementMatch firstElement = phraseData.getElementMatchBeforeOperation(RETURNS_VALUE); + List preceding = phraseData.getElementMatchesPrecedingOperation(RETURNS_VALUE); + // A reference such as is resolved to a bare word before parsing, so + // "has value" has no element. The word itself is the subject. + List values = preceding.isEmpty() + ? List.of(createValueWrapper(literalValueSubject(phraseData))) + : preceding.getFirst().getValues(); phraseData.result = Attempt.run(repetition, 500, () -> { return ValueWrapperCompareReducer.evalValues( ValueWrapper::hasResolvedValue, - firstElement.getValues(), + values, getModeSet(phraseData) ); }); @@ -517,6 +522,18 @@ public void execute(PhraseData phraseData) { }; + private static String literalValueSubject(PhraseData phraseData) { + String body = phraseData.body == null ? "" : phraseData.body.trim(); + if (body.isEmpty()) { + String resolved = phraseData.getResolvedText(); + body = resolved == null ? "" : resolved.trim(); + } + return body + .replaceFirst("(?i)\\b(?:has|is)\\s+(?:no\\s+)?values?\\s*$", "") + .replaceFirst("(?i)^(?:if|else\\s+if|else|until|ensure|verify|the)\\s+", "") + .trim(); + } + private static ValueWrapper createLiteralRegexWrapper(ValueWrapper val, String assertion, String prefix, String suffix) { if (val == null) { throw new IllegalArgumentException(assertion + " assertion cannot use a null ValueWrapper"); diff --git a/src/main/java/tools/dscode/coredefinitions/ModularScenarios.java b/src/main/java/tools/dscode/coredefinitions/ModularScenarios.java index 05de41c20..880771750 100644 --- a/src/main/java/tools/dscode/coredefinitions/ModularScenarios.java +++ b/src/main/java/tools/dscode/coredefinitions/ModularScenarios.java @@ -38,6 +38,8 @@ public class ModularScenarios extends CoreSteps { static final String RUN_TAGS = "Run Tags"; static final String RUN_KEY = "RunKey"; static final String RUN_TYPE = "RunType"; + static final String RUN_IF = "RunIf"; + static final String RUN_BACKGROUND = "RunBackground"; static final String TAGS = "Tags"; static final String CUCUMBER_FEATURES = "cucumber.features"; static final String STEP_MARKER = "Step_Marker"; @@ -61,8 +63,17 @@ public static void runScenarios( return; } + boolean runIfColumn = maps.stream().anyMatch(row -> row.containsKey(RUN_IF)); + boolean runBackgroundColumn = maps.stream().anyMatch(row -> row.containsKey(RUN_BACKGROUND)); List matches = new ArrayList<>(); for (Map map : maps) { + if (runIfColumn && !RunRowCondition.truthy(map.get(RUN_IF))) { + continue; + } + boolean includeBackground = runBackgroundColumn + && RunRowCondition.truthy(map.get(RUN_BACKGROUND)); + map.remove(RUN_IF); + map.remove(RUN_BACKGROUND); RunSelection selection = resolveRunSelection(map, runTypeText); RunType runType = selection.runType(); List rowMatches = collectMatches( @@ -77,7 +88,13 @@ public static void runScenarios( .toList(), selection ); - matches.addAll(rowMatches); + for (PickleMatch rowMatch : rowMatches) { + matches.add(new PickleMatch( + rowMatch.pickle(), + rowMatch.passedValues(), + includeBackground + )); + } } appendMatches( @@ -646,6 +663,8 @@ private static List collectMatches( scanOptions.remove(STEP_MARKER); scanOptions.remove(RUN_KEY); scanOptions.remove(RUN_TYPE); + scanOptions.remove(RUN_IF); + scanOptions.remove(RUN_BACKGROUND); removeBlankPathOption(scanOptions, PKB_FEATURES); removeBlankPathOption(scanOptions, CUCUMBER_FEATURES); if (runType == null) { @@ -796,7 +815,8 @@ private static ScenarioStep prepareScenarioStep( ScenarioStep scenarioStep = createScenarioStep( match.pickle(), scenarioStepParsingMap, - match.passedValues().get(STEP_MARKER) + match.passedValues().get(STEP_MARKER), + match.includeBackground() ); scenarioStep.setStepParsingMap(ParsingMap.getRunningParsingMap()); if (scenarioInitializer != null) { @@ -1046,7 +1066,11 @@ record DataAddress( } private record PickleMatch( Pickle pickle, - Map passedValues + Map passedValues, + boolean includeBackground ) { + private PickleMatch(Pickle pickle, Map passedValues) { + this(pickle, passedValues, true); + } } } diff --git a/src/main/java/tools/dscode/coredefinitions/RunRowCondition.java b/src/main/java/tools/dscode/coredefinitions/RunRowCondition.java new file mode 100644 index 000000000..7251bed6b --- /dev/null +++ b/src/main/java/tools/dscode/coredefinitions/RunRowCondition.java @@ -0,0 +1,115 @@ +package tools.dscode.coredefinitions; + +import io.cucumber.core.runner.StepExtension; +import tools.dscode.common.control.ControlExecutionScope; +import tools.dscode.common.mappings.ParsingMap; +import tools.dscode.common.treeparsing.parsedComponents.PhraseData; +import tools.dscode.common.treeparsing.preparsing.ParsedLine; + +import java.util.regex.Pattern; + +import static io.cucumber.core.runner.GlobalState.getRunningStep; + +/** + * Evaluates a reserved RUN-table cell with the block-conditional IF: phrase engine. + * Blank, the word null, and a whole-cell reference that does not resolve are false + * before that engine runs, because those inputs are not phrases. + */ +final class RunRowCondition { + private static final Pattern SINGLE_REFERENCE = Pattern.compile("^<[^<>]+>$"); + + private RunRowCondition() { + } + + static boolean truthy(String cell) { + String text = cell == null ? "" : cell.trim(); + if (text.isEmpty() || "null".equalsIgnoreCase(text)) { + return false; + } + if (SINGLE_REFERENCE.matcher(text).matches()) { + String resolved = ParsingMap.getRunningParsingMap().resolveWholeText(text); + if (unresolved(resolved)) { + return false; + } + text = resolved.trim(); + } + if ("true".equalsIgnoreCase(text) || "false".equalsIgnoreCase(text)) { + return Boolean.parseBoolean(text); + } + return evaluateBlockCondition(text); + } + + private static boolean unresolved(String resolved) { + if (resolved == null) { + return true; + } + String trimmed = resolved.trim(); + return trimmed.isEmpty() + || "null".equalsIgnoreCase(trimmed) + || SINGLE_REFERENCE.matcher(trimmed).matches(); + } + + private static boolean evaluateBlockCondition(String cell) { + StepExtension parent = getRunningStep(); + if (parent == null) { + throw new IllegalStateException("A RUN row condition requires a running step"); + } + StepExtension child = parent.createNewStepExtension("IF: " + cell); + child.parentStep = parent; + child.nextSibling = null; + child.previousSibling = null; + child.waitForPageReady = false; + DynamicSteps dynamicSteps = new DynamicSteps(); + ControlExecutionScope.withStep(child, () -> { + child.lineData = ParsedLine.createParsedLine(child); + child.lineData.setInheritance(child); + dynamicSteps.executeDynamicStep(cell); + return null; + }); + return conditionResult(child); + } + + private static boolean conditionResult(StepExtension child) { + if (child.lineData == null || child.lineData.phrases().isEmpty()) { + throw new IllegalStateException( + "Run condition did not parse: " + child.getUnmodifiedText()); + } + Boolean found = null; + for (PhraseData phrase : child.lineData.phrases()) { + found = later(found, booleanOf(phrase)); + PhraseData resolved = phrase.getResolvedPhrase(); + if (resolved != null && resolved != phrase) { + found = later(found, booleanOf(resolved)); + } + } + for (PhraseData phrase : child.lineData.executedPhrases) { + found = later(found, booleanOf(phrase)); + } + if (found == null) { + throw new IllegalStateException( + "Run condition did not evaluate to a boolean: " + child.getUnmodifiedText()); + } + return found; + } + + private static Boolean later(Boolean current, Boolean next) { + return next == null ? current : next; + } + + /** + * The IF engine stores the boolean on the resolved phrase, often as the + * assertion-chain status, after {@code resolvePhrase} copies the original. + */ + private static Boolean booleanOf(PhraseData phrase) { + if (phrase == null) { + return null; + } + if (phrase.assertionChain != null && phrase.assertionChain.chainStatus != null) { + return phrase.assertionChain.chainStatus; + } + if (phrase.result != null && phrase.result.value() instanceof Boolean value) { + return value; + } + return null; + } +} diff --git a/src/main/java/tools/dscode/testengine/PickleballRunner.java b/src/main/java/tools/dscode/testengine/PickleballRunner.java index 4b3cf1307..447d9dd43 100644 --- a/src/main/java/tools/dscode/testengine/PickleballRunner.java +++ b/src/main/java/tools/dscode/testengine/PickleballRunner.java @@ -110,6 +110,7 @@ protected PickleballRunner() { applyPkbAliases(); applyLegacyFrameworkDefaults(); syncCanonicalAndAliasKeys(); + alignCucumberNameFilterWithFind(); syncReportPortalAliases(true); ParsingMap.initializeConfigs(values.get(PKB_CONFIG_PATH)); refreshRunProfile(); @@ -187,6 +188,7 @@ public synchronized void captureCucumberCliArgs(String[] argv) { putCliOverride(PKB_NAME, FILTER_NAME_PROPERTY_NAME, joinNameFilters(projection.names)); putCliOverride(PKB_GLUE, GLUE_PROPERTY_NAME, joinCommaSeparated(projection.glue)); putCliReference(PKB_CUCUMBER_CLI_FEATURE_SELECTORS, formatCliArgs(projection.features)); + alignCucumberNameFilterWithFind(); refreshRunProfile(); refreshPkbOptions(); @@ -470,6 +472,27 @@ private void syncReportPortalAliases(boolean aliasWins) { } } + /** + * Cucumber discovery selects a scenario when {@code pkb_name} is found inside the + * title. The JUnit engine later skips unless the same pattern matches the entire + * title. Publish the search form on the Cucumber alias and leave {@code pkb_name} as entered. + */ + private void alignCucumberNameFilterWithFind() { + String name = values.get(PKB_NAME); + if (name == null || name.isBlank()) { + return; + } + String published = values.get(FILTER_NAME_PROPERTY_NAME); + if (published != null && !published.equals(name)) { + return; + } + values.put(FILTER_NAME_PROPERTY_NAME, cucumberNameFilterMatchingFind(name)); + } + + static String cucumberNameFilterMatchingFind(String namePattern) { + return "(?s).*(?:" + namePattern + ").*"; + } + private void syncPair(String aliasKey, String canonicalKey) { String aliasValue = values.get(aliasKey); String canonicalValue = values.get(canonicalKey); 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 6fc71e728..85c7702ae 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 @@ -18,14 +18,14 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | -| Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | -| Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | +| Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `RunRowCondition.java`; `CalledFeatureBackground.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection`, `RunIf`, `RunBackground` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; `run-row-gates.feature`; `SyntaxProofSteps.java`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | +| Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | | Date/time utilities | `DateTimeUtilitySteps.java`; `common/util/datetime` | date/time consumer feature and Java checks | `docs/date-time-utilities.md` | | Dialog handling | `BrowserAlerts.java`; browser definitions | `dialogs.feature` | dynamic-step/custom-element docs | | Cucumber compatibility/AspectJ weaving | `src/main/aspectj/io/cucumber`; `src/main/java/io/cucumber`; `build.gradle` | consumer runner and full feature suite | `docs/cucumber-compatibility.md`; `docs/feature-status-notes.md` | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index 296e28d91..c5ac77b07 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 @@ -91,6 +91,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/io/cucumber/core/gherkin/messages/MessageUtilities.java` - `src/main/java/io/cucumber/core/gherkin/messages/NGherkinFactory.java` - `src/main/java/io/cucumber/core/runner/ArgStepFunctions.java` +- `src/main/java/io/cucumber/core/runner/CalledFeatureBackground.java` - `src/main/java/io/cucumber/core/runner/CoreConstants.java` - `src/main/java/io/cucumber/core/runner/CucumberStepInvoker.java` - `src/main/java/io/cucumber/core/runner/CurrentScenarioState.java` @@ -369,6 +370,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/coredefinitions/NavigationSteps.java` - `src/main/java/tools/dscode/coredefinitions/ObjectRegistrationSteps.java` - `src/main/java/tools/dscode/coredefinitions/ReportingSteps.java` +- `src/main/java/tools/dscode/coredefinitions/RunRowCondition.java` - `src/main/java/tools/dscode/coredefinitions/ServiceCallSteps.java` - `src/main/java/tools/dscode/coredefinitions/TableSteps.java` - `src/main/java/tools/dscode/coredefinitions/UtilitySteps.java` @@ -421,6 +423,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java` - `src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorTest.java` +- `src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` - `src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java` @@ -656,6 +659,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/com/example/pickleball/StepOverrideChecks.java` - `maven-consumer-project/src/test/java/com/example/pickleball/support/InternalJavaTestRunner.java` - `maven-consumer-project/src/test/java/com/example/pickleball/support/LocalTestSite.java` +- `maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java` - `maven-consumer-project/src/test/java/com/example/pickleball/tests/TokenizedQueryTest.java` - `maven-consumer-project/src/test/java/io/cucumber/core/runner/ScenarioStepChecks.java` - `maven-consumer-project/src/test/java/io/cucumber/core/runner/ScenarioStepDataChecks.java` @@ -702,6 +706,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/features/diagnostic-reporting-validation.feature` - `maven-consumer-project/src/test/resources/features/dialogs.feature` - `maven-consumer-project/src/test/resources/features/dynamic-steps.feature` +- `maven-consumer-project/src/test/resources/features/example-row-proof.feature` - `maven-consumer-project/src/test/resources/features/forms-dynamic-steps.feature` - `maven-consumer-project/src/test/resources/features/internal-framework-java-checks.feature` - `maven-consumer-project/src/test/resources/features/it-placeholder.feature` @@ -713,12 +718,14 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/features/nested-and-block-conditionals.feature` - `maven-consumer-project/src/test/resources/features/pickleball-2.1.2-syntax-demo.feature` - `maven-consumer-project/src/test/resources/features/reusable-scenario-selection.feature` +- `maven-consumer-project/src/test/resources/features/run-row-gates.feature` - `maven-consumer-project/src/test/resources/features/run-step-parameter-variations.feature` - `maven-consumer-project/src/test/resources/features/scenario-data-references.feature` - `maven-consumer-project/src/test/resources/features/scenario-marker-data.feature` - `maven-consumer-project/src/test/resources/features/scenario-step-markers.feature` - `maven-consumer-project/src/test/resources/features/service-call-execution.feature` - `maven-consumer-project/src/test/resources/features/step-override-bridge.feature` +- `maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature` ## Maven consumer service-call definitions diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/component-scenarios.md b/src/main/resources/META-INF/pickleball/guidance/docs/component-scenarios.md index 83ef28ecb..84a731e3b 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/component-scenarios.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/component-scenarios.md @@ -1,6 +1,6 @@ # Component Scenarios -> **Working feature examples:** [`component-scenarios.feature`](../maven-consumer-project/src/test/resources/features/component-scenarios.feature) contains a reusable component. [`reusable-scenario-selection.feature`](../maven-consumer-project/src/test/resources/features/reusable-scenario-selection.feature) demonstrates named selectors, escaped names, ordering, limits, and singular/plural behavior. [`run-step-parameter-variations.feature`](../maven-consumer-project/src/test/resources/features/run-step-parameter-variations.feature) demonstrates the canonical table-driven `RUN` form, mixed run types, and shorthand variations. [`scenario-data-references.feature`](../maven-consumer-project/src/test/resources/features/scenario-data-references.feature) demonstrates marker and data-file references. +> **Working feature examples:** [`component-scenarios.feature`](../maven-consumer-project/src/test/resources/features/component-scenarios.feature) contains a reusable component. [`reusable-scenario-selection.feature`](../maven-consumer-project/src/test/resources/features/reusable-scenario-selection.feature) demonstrates named selectors, escaped names, ordering, limits, and singular/plural behavior. [`run-step-parameter-variations.feature`](../maven-consumer-project/src/test/resources/features/run-step-parameter-variations.feature) demonstrates the canonical table-driven `RUN` form, mixed run types, and shorthand variations. [`syntax-since-2.1.14.feature`](../maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature) demonstrates `RunIf`, `RunBackground`, and inline `DT:::` inheritance. [`scenario-data-references.feature`](../maven-consumer-project/src/test/resources/features/scenario-data-references.feature) demonstrates marker and data-file references. Component scenarios are reusable, scenario-sized flows. `RUN` is the common dispatcher for regular scenarios, component scenarios, and service calls. @@ -110,12 +110,14 @@ Blank components and more than three unescaped components are rejected. ## Invocation options -Every invocation-table column is passed to the scenario scan. Common options include: +Every invocation-table column except the reserved `RunIf` and `RunBackground` columns is passed to the scenario scan. Those two columns are not search filters and are not merged into the called scenario. Common options include: | Purpose | Pickleball option | Cucumber option | |---|---|---| | Run kind/cardinality | `RunType` | — | | Saved result key | `RunKey` | — | +| Row gate | `RunIf` | — | +| Called-feature background | `RunBackground` | — | | Component feature path | `pkb_componentpath` | — | | Exact feature name | `pkb_featurename` | — | | Scenario-name regex | `pkb_name` | `cucumber.filter.name` | @@ -124,6 +126,20 @@ Every invocation-table column is passed to the scenario scan. Common options inc | Result limit | `pkb_limit` | `cucumber.execution.limit` | | Start marker | `Step_Marker` | — | +### RunIf and RunBackground + +`RunIf` is evaluated in the calling scenario before that row is searched or started. The cell uses the same evaluator as the text after `IF:` in a block conditional. True runs the row. False skips it. A skipped row does not fail `RUN`, does not start its called scenario, and later rows are still evaluated. When one row matches several scenarios, that whole match runs or skips together. + +A blank cell, the word `null`, or a reference that does not resolve is false. Explicit `true` and `false` values work. Dynamic assertions such as ` has value` and block comparisons such as `4 > 3` work. When the `RunIf` column is absent, every row runs. + +`RunBackground` uses that same evaluation. Background steps in the called feature run only when the cell is true. A blank, null, or unresolved cell does not run them. When the `RunBackground` column is absent, `RUN` ignores called-feature backgrounds. That is the default for every `RUN` data table, including scenario, component-scenario, and service-call rows. `SCENARIO:`, `COMPONENT:`, `CALL:`, and a root scenario keep their existing background behavior. + +```gherkin +* RUN + | RunType | RunKey | RunIf | RunBackground | + | SCENARIO | setup | has value | true | +``` + ```gherkin * RUN | RunType | pkb_featurename | pkb_name | pkb_order | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index cf3e6b4e8..03a6a51f4 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -401,7 +401,7 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, ## Example row filter -`pkb_example` keeps specific Examples rows from scenarios that `pkb_tags`, `pkb_name`, and, on a component or `listPickles` scan, `pkb_featurename` have already selected. Where those filters select scenarios, `pkb_example` runs after them. It is not a tag and it does not extend Cucumber tag expressions (`and` / `or` / `not`). It has no `cucumber.filter` alias. `pkb_name` still matches the Scenario or Scenario Outline title and still selects every row until `pkb_example` runs. A normal Maven run applies the same filter after Cucumber's tag and name filters. It does not start honoring `pkb_featurename` on that engine path. +`pkb_example` keeps specific Examples rows from scenarios that `pkb_tags`, `pkb_name`, and, on a component or `listPickles` scan, `pkb_featurename` have already selected. Where those filters select scenarios, `pkb_example` runs after them. It is not a tag and it does not extend Cucumber tag expressions (`and` / `or` / `not`). It has no `cucumber.filter` alias. `pkb_name` is a search of the Scenario or Scenario Outline title, the same rule Cucumber uses when it discovers scenarios. `Partial name` selects `Partial name example rows`. It still selects every row of that scenario until `pkb_example` runs. The Maven run keeps a scenario that this search selected. A normal Maven run applies the same filter after Cucumber's tag and name filters. It does not start honoring `pkb_featurename` on that engine path. Absent or blank `pkb_example` adds no filter. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/service-call-scenarios.md b/src/main/resources/META-INF/pickleball/guidance/docs/service-call-scenarios.md index e4af6cec7..98d922998 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/service-call-scenarios.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/service-call-scenarios.md @@ -135,6 +135,8 @@ Both store explicit `RETURN` when present and otherwise store the completed scen |---|---|---| | Run kind/cardinality | `RunType` | — | | Saved result key | `RunKey` | — | +| Row gate | `RunIf` | — | +| Called-feature background | `RunBackground` | — | | Call feature path | `pkb_callpath` | — | | Exact feature name | `pkb_featurename` | — | | Scenario-name regex | `pkb_name` | `cucumber.filter.name` | @@ -143,6 +145,8 @@ Both store explicit `RETURN` when present and otherwise store the completed scen | Result limit | `pkb_limit` | `cucumber.execution.limit` | | Start marker | `Step_Marker` | — | +`RunIf` and `RunBackground` are reserved on this same `RUN` table. They are not search filters and they are not passed into the called scenario. `RunIf` is evaluated in the caller, before the row starts, with the same rules as the text after `IF:` in a block conditional. False skips the row without failing `RUN`. `RunBackground` true runs background steps in the called feature. Blank, null, and unresolved cells are false for both columns. When `RunIf` is absent, every row runs. When `RunBackground` is absent, `RUN` ignores called-feature backgrounds. That default applies to every `RUN` row, including `SERVICE CALL`. `CALL:` is unchanged. + Inline feature/scenario/marker components overwrite their equivalent table selector fields. A nonblank table `RunType` overrides the inline run type, and `RunKey` keeps its precedence over a quoted key. ## Singular and plural behavior diff --git a/src/main/resources/META-INF/pickleball/guidance/index.txt b/src/main/resources/META-INF/pickleball/guidance/index.txt index d9f4ba60a..ecab9ce32 100644 --- a/src/main/resources/META-INF/pickleball/guidance/index.txt +++ b/src/main/resources/META-INF/pickleball/guidance/index.txt @@ -72,6 +72,7 @@ maven-consumer-project/src/test/resources/features/date-time-utilities.feature maven-consumer-project/src/test/resources/features/diagnostic-reporting-validation.feature maven-consumer-project/src/test/resources/features/dialogs.feature maven-consumer-project/src/test/resources/features/dynamic-steps.feature +maven-consumer-project/src/test/resources/features/example-row-proof.feature maven-consumer-project/src/test/resources/features/forms-dynamic-steps.feature maven-consumer-project/src/test/resources/features/internal-framework-java-checks.feature maven-consumer-project/src/test/resources/features/it-placeholder.feature @@ -83,12 +84,14 @@ maven-consumer-project/src/test/resources/features/navigation.feature maven-consumer-project/src/test/resources/features/nested-and-block-conditionals.feature maven-consumer-project/src/test/resources/features/pickleball-2.1.2-syntax-demo.feature maven-consumer-project/src/test/resources/features/reusable-scenario-selection.feature +maven-consumer-project/src/test/resources/features/run-row-gates.feature maven-consumer-project/src/test/resources/features/run-step-parameter-variations.feature maven-consumer-project/src/test/resources/features/scenario-data-references.feature maven-consumer-project/src/test/resources/features/scenario-marker-data.feature maven-consumer-project/src/test/resources/features/scenario-step-markers.feature maven-consumer-project/src/test/resources/features/service-call-execution.feature maven-consumer-project/src/test/resources/features/step-override-bridge.feature +maven-consumer-project/src/test/resources/features/syntax-since-2.1.14.feature maven-consumer-project/src/test/resources/files/customers.yaml maven-consumer-project/src/test/resources/pickleball.properties maven-consumer-project/src/test/resources/pickleball_local.properties diff --git a/src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java b/src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java new file mode 100644 index 000000000..0a81cd1d4 --- /dev/null +++ b/src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java @@ -0,0 +1,27 @@ +package tools.dscode.testengine; + +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CucumberNameFilterTest { + + @Test + void partialScenarioNameMatchesTheWholeTitleOnTheJunitEngine() { + Pattern partial = Pattern.compile(PickleballRunner.cucumberNameFilterMatchingFind("Partial name")); + assertTrue(partial.matcher("Partial name example rows").matches()); + assertTrue(partial.matcher("Partial name plain scenario").matches()); + assertFalse(partial.matcher("Example #1.2").matches()); + } + + @Test + void anchoredNameStillMatchesOnlyTheWholeTitle() { + Pattern exact = Pattern.compile(PickleballRunner.cucumberNameFilterMatchingFind("^Exact title$")); + assertTrue(exact.matcher("Exact title").matches()); + assertFalse(exact.matcher("Exact title extra").matches()); + assertFalse(exact.matcher("Not Exact title").matches()); + } +} From 822f00f8f437472a595ba51512e22982c0f6b873 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Fri, 2 Oct 2026 21:23:14 +0000 Subject: [PATCH 15/23] Bottom line: a normal mvn test no longer opens a private run or shares one Chrome profile. Agent and Workbench runs still keep .pickleball/runs//, and each parallel local Chrome or Edge gets browser-profile/. pkb_compositeReport and pkb_scenarioReport stay on for a normal test and default to false for an agent or Workbench run; explicit true writes them again. --- AGENTS.md | 2 + docs/agent/feature-map.md | 2 + docs/ai-run-configuration.md | 4 +- docs/configuration.md | 10 +- docs/consumer-agent-guide.md | 6 +- pickleball-workbench/AGENTS.md | 4 +- .../workbench/ui/RunVarOverridePanel.java | 19 ++- .../workbench/ui/RunVarOverridePanelTest.java | 2 + .../diagnostic/DiagnosticRuntimeAspect.aj | 15 +- .../diagnostic/ReportRetentionAspect.aj | 31 +++- .../coordination/AgentCoordination.java | 103 +++++++++++- .../diagnostic/AgentDiscoverPlanner.java | 2 + .../simplehtml/SimpleHtmlReportConverter.java | 37 ++--- .../tools/dscode/testengine/PKB_props.java | 2 + .../dscode/testengine/PickleballRunner.java | 19 ++- .../pickleball/guidance/AGENT-GUIDE.md | 6 +- .../guidance/docs/agent/feature-map.md | 2 + .../guidance/docs/ai-run-configuration.md | 4 +- .../pickleball/guidance/docs/configuration.md | 10 +- .../guidance/docs/consumer-agent-guide.md | 6 +- .../coordination/AgentCoordinationTest.java | 154 +++++++++++++++++- .../diagnostic/AgentDiscoverPlannerTest.java | 2 + .../launcher/WorkbenchAgentCommandsTest.java | 3 + 23 files changed, 385 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4d012fdb9..5f1ac8db3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,8 @@ The published Pickleball JAR embeds the completed controller-only Workbench JAR The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. A private `.pickleball/runs//` directory is only for an agent or Workbench run. A normal `mvn test` does not create it, does not set `user-data-dir`, and still writes both HTML reports. Parallel workers in one private run each get `browser-profile/`. `pkb_compositeReport=false` and `pkb_scenarioReport=false` are the agent and Workbench defaults. Do not change the `pkb_parallel` default. Isolate stays one scenario. Do not treat resolve-runvars as Discover's environment. Trust the post-Discover run record. Sealed input is `pkb_overriderunvars` only. + ## Build and validation Use Java 21. diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 85c7702ae..efe03d0e4 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -51,4 +51,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not open the GUI for your own testing to obtain it. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. + 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/ai-run-configuration.md b/docs/ai-run-configuration.md index e39388551..21eb0454c 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -308,7 +308,7 @@ Example compact rerun: For an agent's bounded confirmation, run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. This is not the `PickleballTests` human defaults of `pretty` / `@all`. Include diagnostic evidence controls, headless Chrome, and high parallelism when more than one scenario will run: ```text --Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_parallel=auto, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +-Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_parallel=auto, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed, pkb_compositeReport=false, pkb_scenarioReport=false" ``` Lineage metadata is not execution configuration: @@ -440,6 +440,8 @@ pkb_parallel= pkb_reportingmode=diagnostic pkb_loglevel=warn pkb_reportretention=failed +pkb_compositeReport=false +pkb_scenarioReport=false ``` plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. diff --git a/docs/configuration.md b/docs/configuration.md index 03a6a51f4..c792d4e82 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,9 +110,13 @@ Lineage and coordination metadata survive controlled execution but are excluded Different consumer agents can run different tests at the same time. The shared files under the consumer `.pickleball` directory are a bulletin board. Run data stays private to each run. -Every discover, confirm, isolate, and normal consumer run gets a unique run id when the caller does not pass one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. +A private `.pickleball/runs//` directory is only for an agent run or a Workbench run (`-Dpkb_run_id`, `-Dpkb_agent_id`, or a Workbench worker). A normal `mvn test` does not open that folder, does not set Chrome or Edge `user-data-dir`, and writes the composite HTML report to `reports/cucumber-report.html`. Diagnostic packs and scratch stay on their 2.1.13 paths for a normal test. The short log and inbox stay where they are. -Reports, diagnostic packs, scratch, the local browser profile, and a headless Workbench session file for that run live under `.pickleball/runs//`. Two runs never write the same output folder, the same browser profile, or the same live session file. +An agent or Workbench run that does not pass a run id gets one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. + +Reports, diagnostic packs, scratch, and a headless Workbench session file for that private run live under `.pickleball/runs//`. Local Chrome and Edge in one run do not share one profile. Each parallel worker gets `browser-profile/`. Remote drivers are unchanged. Options that already contain `user-data-dir` are left alone. Two runs never write the same output folder or the same live session file. + +`pkb_compositeReport=false` suppresses the composite `reports/cucumber-report.html`. `pkb_scenarioReport=false` suppresses the per-scenario workbook HTML that `Log.closeAll` writes. A normal test leaves both unset and still writes both reports. An agent run, and a Workbench run including a person collaborating with an agent, default both to false. Agents keep diagnostic logs. An explicit `true` writes that report again. `pkb_reportingmode=diagnostic` still bypasses automatic HTML, ReportPortal, and XLSX unless one of those HTML run vars is explicitly true. The run record is `.pickleball/runs//record.json`: run id, agent id, optional group, sequence, who, why, what was learned, start time, stop time, status, and the path to the run data. The short log only points at that record. @@ -394,6 +398,8 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_parallel` | `4`, `auto` | parallel scenario count; `auto` resolves at run start to a conservative JVM estimate and stamps the integer into `pkb_run_profile` | | `pkb_loglevel` | `debug` | console log level | | `pkb_reportingmode` | `diagnostic` | diagnostic evidence pipeline | +| `pkb_compositeReport` | `true`, `false` | composite `reports/cucumber-report.html`; unset writes it on a normal test and suppresses it on an agent or Workbench run | +| `pkb_scenarioReport` | `true`, `false` | per-scenario workbook HTML; same default as `pkb_compositeReport` | | `pkb_reportretention` | `all`, `failed`, `none` | automatic evidence/report retention | | `pkb_diagnostic_output` | `reports/diagnostic-runs` | optional diagnostic output root | | `pkb_platformlog` | `default`, `default+git`, `none`, etc. | platform/caller log stamps | diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 08e2eb665..d70c3b486 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -10,6 +10,8 @@ Pickleball Workbench is the one front door. It is a Java program launched from t Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. + From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use that wrapper and the same exec invocation as the project pointer; only change `-Dexec.args`: ```text @@ -37,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. Reports, diagnostic packs, scratch, the browser profile, and a headless session file for that run stay under that directory. Two runs do not share an output folder, a browser profile, or a live session. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder or a live session. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. ### Live isolation loop @@ -168,6 +170,8 @@ pkb_parallel= pkb_reportingmode=diagnostic pkb_loglevel=warn pkb_reportretention=failed +pkb_compositeReport=false +pkb_scenarioReport=false ``` Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index c4f868e33..fdde3479c 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -73,7 +73,9 @@ center: Live Gherkin text editor + compact Step Editor / Command right: Mapping | Terminal | Explorer | Report ``` -The Sealed RunVars panel is JDK/Workbench-only: it displays a map and writes LastDiscoverSnapshot `sealed=true` for the next worker `-Dpkb_overriderunvars=`. It must not import `PKB_props`, `PickleballProfiles`, or Pickleball core. Panel edits do not mutate an in-flight worker. Unused, it changes no behavior. +The Sealed RunVars panel is JDK/Workbench-only: it displays a map and writes LastDiscoverSnapshot `sealed=true` for the next worker `-Dpkb_overriderunvars=`. It must not import `PKB_props`, `PickleballProfiles`, or Pickleball core. Panel edits do not mutate an in-flight worker. Unused, it changes no behavior. The panel shows `pkb_compositereport` and `pkb_scenarioreport`. Agent and Workbench runs default both to false. A normal test still writes both HTML reports. An explicit true writes that report again. + +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. Low-level lifecycle controls live under the Session menu and existing investigation controls remain available under Advanced Controls rather than dominating the permanent workspace. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/RunVarOverridePanel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/RunVarOverridePanel.java index 8ffacf0e3..016de0b16 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/RunVarOverridePanel.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/RunVarOverridePanel.java @@ -117,10 +117,17 @@ Map currentValues() { private void rebuildFields(Map values) { fields.removeAll(); rows.clear(); - if (values.isEmpty()) { + Map shown = new TreeMap<>(); + if (values != null) shown.putAll(values); + if (values != null && !values.isEmpty()) { + for (String key : HTML_REPORT_KEYS) { + shown.putIfAbsent(key, "false"); + } + } + if (shown.isEmpty()) { fields.add(WorkbenchTheme.muted("No retained RunVars.")); } else { - values.forEach((key, value) -> { + shown.forEach((key, value) -> { FieldRow row = FieldRow.create(key, value == null ? "" : value); rows.add(row); fields.add(row.component()); @@ -163,7 +170,13 @@ private static boolean booleanValue(String value) { private static final java.util.Set BOOLEAN_KEYS = java.util.Set.of( "pkb_debugbrowser", - "pkb_rp_enable" + "pkb_rp_enable", + "pkb_compositereport", + "pkb_scenarioreport" + ); + private static final List HTML_REPORT_KEYS = List.of( + "pkb_compositereport", + "pkb_scenarioreport" ); enum Kind { BOOLEAN, ENUM, TEXT } diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/RunVarOverridePanelTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/RunVarOverridePanelTest.java index 813696456..831aa2256 100644 --- a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/RunVarOverridePanelTest.java +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/RunVarOverridePanelTest.java @@ -17,6 +17,8 @@ class RunVarOverridePanelTest { void booleanLikeKeysUseToggles() { assertEquals(RunVarOverridePanel.Kind.BOOLEAN, RunVarOverridePanel.kindFor("pkb_debugBrowser", "false")); assertEquals(RunVarOverridePanel.Kind.BOOLEAN, RunVarOverridePanel.kindFor("pkb_rp_enable", "true")); + assertEquals(RunVarOverridePanel.Kind.BOOLEAN, RunVarOverridePanel.kindFor("pkb_compositereport", "false")); + assertEquals(RunVarOverridePanel.Kind.BOOLEAN, RunVarOverridePanel.kindFor("pkb_scenarioreport", "true")); assertEquals(RunVarOverridePanel.Kind.BOOLEAN, RunVarOverridePanel.kindFor("pkb_custom", "yes")); } diff --git a/src/main/aspectj/tools/dscode/common/reporting/diagnostic/DiagnosticRuntimeAspect.aj b/src/main/aspectj/tools/dscode/common/reporting/diagnostic/DiagnosticRuntimeAspect.aj index 0e00a4593..242bccc6d 100644 --- a/src/main/aspectj/tools/dscode/common/reporting/diagnostic/DiagnosticRuntimeAspect.aj +++ b/src/main/aspectj/tools/dscode/common/reporting/diagnostic/DiagnosticRuntimeAspect.aj @@ -7,10 +7,12 @@ import io.cucumber.datatable.DataTable; import io.cucumber.plugin.event.Result; import org.openqa.selenium.WebDriver; import org.openqa.selenium.remote.RemoteWebDriver; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.reporting.logging.BaseConverter; import tools.dscode.common.reporting.logging.Entry; import tools.dscode.common.reporting.logging.Level; import tools.dscode.common.reporting.logging.Status; +import tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter; import tools.dscode.coredefinitions.BrowserSteps; import java.util.concurrent.CompletableFuture; @@ -238,15 +240,22 @@ public privileged aspect DiagnosticRuntimeAspect { } void around(): execution(void tools.dscode.common.reporting.logging.BaseConverter+.onStart(..)) { - if (!DiagnosticRuntime.isDiagnostic()) proceed(); + if (collectHtmlInDiagnostic(thisJoinPoint.getTarget())) proceed(); } void around(): execution(void tools.dscode.common.reporting.logging.BaseConverter+.onTimestamp(..)) { - if (!DiagnosticRuntime.isDiagnostic()) proceed(); + if (collectHtmlInDiagnostic(thisJoinPoint.getTarget())) proceed(); } void around(): execution(void tools.dscode.common.reporting.logging.BaseConverter+.onStop(..)) { - if (!DiagnosticRuntime.isDiagnostic()) proceed(); + if (collectHtmlInDiagnostic(thisJoinPoint.getTarget())) proceed(); + } + + private static boolean collectHtmlInDiagnostic(Object target) { + if (!DiagnosticRuntime.isDiagnostic()) return true; + return target instanceof SimpleHtmlReportConverter + && (AgentCoordination.htmlEnabled("compositeReport") + || AgentCoordination.htmlEnabled("scenarioReport")); } Entry around(): execution(public tools.dscode.common.reporting.logging.Entry tools.dscode.common.reporting.logging.Entry.close()) { diff --git a/src/main/aspectj/tools/dscode/common/reporting/diagnostic/ReportRetentionAspect.aj b/src/main/aspectj/tools/dscode/common/reporting/diagnostic/ReportRetentionAspect.aj index 090aec5e5..60de4574b 100644 --- a/src/main/aspectj/tools/dscode/common/reporting/diagnostic/ReportRetentionAspect.aj +++ b/src/main/aspectj/tools/dscode/common/reporting/diagnostic/ReportRetentionAspect.aj @@ -2,6 +2,7 @@ package tools.dscode.common.reporting.diagnostic; import io.cucumber.core.runner.CurrentScenarioState; import io.cucumber.datatable.DataTable; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.reporting.WorkBook; import tools.dscode.common.reporting.logging.Log; import tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter; @@ -13,13 +14,15 @@ public privileged aspect ReportRetentionAspect { void around(SimpleHtmlReportConverter converter): execution(protected void tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter.onClose()) && this(converter) { - if (DiagnosticRuntime.isDiagnostic()) { + if (automaticHtmlBlocked()) { converter.scopes.clear(); return; } CurrentScenarioState state = io.cucumber.core.runner.GlobalState.getCurrentScenarioState(); boolean failed = state != null && state.isScenarioFailed(); - if (ReportRetentionPolicy.writeAutomaticScenarioFiles(failed, false)) { + if (AgentCoordination.explicitHtmlOn("scenarioReport") + || AgentCoordination.explicitHtmlOn("compositeReport") + || ReportRetentionPolicy.writeAutomaticScenarioFiles(failed, false)) { proceed(converter); } else { converter.scopes.clear(); @@ -27,13 +30,17 @@ public privileged aspect ReportRetentionAspect { } void around(): execution(public static void tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter.writeFinalReport()) { - if (!DiagnosticRuntime.isDiagnostic() && ReportRetentionPolicy.writeAutomaticRunFiles()) proceed(); + if (automaticHtmlBlocked()) return; + if (!explicitHtmlRequested() && !ReportRetentionPolicy.writeAutomaticRunFiles()) return; + proceed(); } void around(Path path): execution(public static void tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter.writeFinalReport(java.nio.file.Path)) && args(path) { - if (!DiagnosticRuntime.isDiagnostic() && ReportRetentionPolicy.writeAutomaticRunFiles()) proceed(path); + if (automaticHtmlBlocked()) return; + if (!explicitHtmlRequested() && !ReportRetentionPolicy.writeAutomaticRunFiles()) return; + proceed(path); } void around() throws java.io.IOException: @@ -49,6 +56,9 @@ public privileged aspect ReportRetentionAspect { return; } ExplicitReportRegistry.writeExplicit(io.cucumber.core.runner.GlobalState.workBookMap.values()); + if ((explicitHtmlRequested() || ReportRetentionPolicy.writeAutomaticRunFiles()) && !automaticHtmlBlocked()) { + SimpleHtmlReportConverter.writeFinalReport(AgentCoordination.reportHtmlOrDefault()); + } } void around(String status): @@ -61,7 +71,7 @@ public privileged aspect ReportRetentionAspect { SimpleHtmlReportConverter around(): call(tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter.new(..)) && (within(io.cucumber.core.runner.GlobalState) || within(io.cucumber.core.runner.CurrentScenarioState)) { - return DiagnosticRuntime.isDiagnostic() ? null : proceed(); + return automaticHtmlBlocked() ? null : proceed(); } tools.dscode.common.reporting.logging.reportportal.ReportPortalBridgeConverter around(): @@ -94,4 +104,15 @@ public privileged aspect ReportRetentionAspect { && args(reportPath, sheetName, dataTable) { ExplicitReportRegistry.mark(reportPath); } + + private static boolean automaticHtmlBlocked() { + return DiagnosticRuntime.isDiagnostic() + && !AgentCoordination.htmlEnabled("compositeReport") + && !AgentCoordination.htmlEnabled("scenarioReport"); + } + + private static boolean explicitHtmlRequested() { + return AgentCoordination.explicitHtmlOn("compositeReport") + || AgentCoordination.explicitHtmlOn("scenarioReport"); + } } diff --git a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java index fd8ebc494..54a2191e2 100644 --- a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java +++ b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java @@ -3,8 +3,10 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.control.protocol.PickleballLocalLayout; import tools.dscode.testengine.PKB_props; +import tools.dscode.testengine.PickleballRunner; import java.io.IOException; import java.nio.ByteBuffer; @@ -30,10 +32,13 @@ * Shared bulletin board for consumer agents, with private data per run. * *

The short log and inbox live under the consumer {@code .pickleball} directory. - * Reports, diagnostic packs, scratch, the browser profile, and a headless - * Workbench session file for that run live under {@code .pickleball/runs/}. - * Appending the short log does not take a file lock. A dead agent must not block - * the others. Lines older than three days are dropped on the next append.

+ * A private {@code .pickleball/runs/} directory is only for an agent run + * or a Workbench run. A normal test does not create that folder and does not + * force a browser profile. Reports, diagnostic packs, scratch, per-worker + * browser profiles, and a headless Workbench session file for a private run + * live under that directory. Appending the short log does not take a file lock. + * A dead agent must not block the others. Lines older than three days are + * dropped on the next append.

*/ public final class AgentCoordination { public static final String LOG_FILE = "agent-log"; @@ -150,12 +155,73 @@ public static Run current() { } public static void clearCurrent() { + Run run = current; current = null; + if (run == null) return; + String scratch = System.getProperty(ATTACHMENT_TEMP_PROPERTY); + if (scratch != null && scratch.equals(run.scratchDirectory().toString())) { + System.clearProperty(ATTACHMENT_TEMP_PROPERTY); + } + } + + /** + * True when this JVM was launched as an agent run ({@code -Dpkb_run_id} or + * {@code -Dpkb_agent_id}) or a Workbench worker. A normal {@code mvn test} + * is neither. + */ + public static boolean launchRequestsPrivateRun() { + return present(System.getProperty(PKB_props.PKB_RUN_ID)) + || present(System.getProperty(PKB_props.PKB_AGENT_ID)) + || present(System.getProperty(ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY)) + || present(System.getenv(ControlProtocol.SESSION_DIRECTORY_ENV)); + } + + /** Folder name for the local browser profile of the current parallel worker. */ + public static String workerKey() { + String name = Thread.currentThread().getName(); + String cleaned = name == null ? "" : name.replaceAll("[^A-Za-z0-9._-]", "-"); + if (cleaned.isBlank()) cleaned = "worker"; + if (cleaned.length() > 60) cleaned = cleaned.substring(0, 60); + return cleaned + "-" + Thread.currentThread().threadId(); } public static Path currentBrowserProfile() { Run run = current; - return run == null ? null : run.browserProfileDirectory(); + return run == null ? null : run.browserProfileDirectory().resolve(workerKey()); + } + + /** + * Explicit {@code pkb_compositereport} / {@code pkb_scenarioreport} value, or null when unset. + * A blank value is explicit. Does not start a {@link PickleballRunner}. + */ + public static String explicitHtmlSetting(String shortName) { + if (shortName == null || shortName.isBlank()) return null; + String key = PKB_props.PKB_PREFIX + shortName.trim().toLowerCase(Locale.ROOT); + if (PickleballRunner.rawInstance() != null) { + String live = liveRunVar(shortName.trim()); + if (live != null) return live; + String resolved = PickleballRunner.rawInstance().get(key); + if (resolved != null) return resolved; + } + return System.getProperty(key); + } + + /** + * Whether to write one HTML report. {@code false} suppresses it. + * Unset writes on a normal test and does not write on an agent or Workbench run. + * Any other non-blank value, including {@code true} or a path, writes. + */ + public static boolean htmlEnabled(String shortName) { + if (explicitHtmlSetting(shortName) == null) return current() == null; + return explicitHtmlOn(shortName); + } + + /** True only when the HTML run var is set to something other than false or blank. */ + public static boolean explicitHtmlOn(String shortName) { + String explicit = explicitHtmlSetting(shortName); + if (explicit == null) return false; + String normalized = stripHtmlQuotes(explicit.trim()); + return !normalized.isBlank() && !"false".equalsIgnoreCase(normalized); } public static Path reportHtmlOrDefault() { @@ -228,6 +294,10 @@ public static Run begin(Path project, Request request, Instant when, Appendable } public static Run openConsumerRun(Path project, Appendable out) { + if (!launchRequestsPrivateRun()) { + clearCurrent(); + return null; + } Request request = Request.of( System.getProperty(PKB_props.PKB_RUN_ID), System.getProperty(PKB_props.PKB_AGENT_ID), @@ -630,6 +700,29 @@ private static String firstNonBlank(String... values) { return null; } + private static String liveRunVar(String shortName) { + try { + Object value = tools.dscode.common.variables.RunVars.resolveFromVars(shortName); + return value == null ? null : value.toString(); + } catch (Throwable ignored) { + return null; + } + } + + private static String stripHtmlQuotes(String raw) { + String value = raw; + while ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'"))) { + if (value.length() < 2) break; + value = value.substring(1, value.length() - 1).trim(); + } + return value; + } + + private static boolean present(String value) { + return value != null && !value.isBlank(); + } + private static String blankToNull(String value) { if (value == null) return null; String trimmed = value.trim(); diff --git a/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java b/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java index bb3da8eb7..1c77ff8e7 100644 --- a/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java +++ b/src/main/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlanner.java @@ -120,6 +120,8 @@ private static LinkedHashMap baseDiscoverVars(String browser) { values.put(PKB_props.PKB_REPORTING_MODE, "diagnostic"); values.put(PKB_props.PKB_LOGLEVEL, "warn"); values.put(PKB_props.PKB_REPORT_RETENTION, "failed"); + values.put(PKB_props.PKB_COMPOSITE_REPORT, "false"); + values.put(PKB_props.PKB_SCENARIO_REPORT, "false"); return values; } diff --git a/src/main/java/tools/dscode/common/reporting/logging/simplehtml/SimpleHtmlReportConverter.java b/src/main/java/tools/dscode/common/reporting/logging/simplehtml/SimpleHtmlReportConverter.java index b0cde4f4a..d6302f994 100644 --- a/src/main/java/tools/dscode/common/reporting/logging/simplehtml/SimpleHtmlReportConverter.java +++ b/src/main/java/tools/dscode/common/reporting/logging/simplehtml/SimpleHtmlReportConverter.java @@ -1,6 +1,7 @@ // file: tools/dscode/common/reporting/logging/simplehtml/SimpleHtmlReportConverter.java package tools.dscode.common.reporting.logging.simplehtml; +import tools.dscode.common.coordination.AgentCoordination; import tools.dscode.common.reporting.logging.Attachment; import tools.dscode.common.reporting.logging.BaseConverter; import tools.dscode.common.reporting.logging.Entry; @@ -230,12 +231,11 @@ public static Path defaultReportFile(Path anyPerScenarioPath) { * Writes report output from the path variables only. * * Rules: - * - compositeReport undefined + scenarioReport disabled/undefined => write composite report to the current default path - * - compositeReport undefined + scenarioReport enabled => do not write composite report - * - compositeReport blank/false => do not write composite report - * - compositeReport true => write composite report to the current default path - * - compositeReport other non-blank value => write composite report to that path/template - * - scenarioReport undefined/blank/false => do not write scenario reports + * - unset writes both reports on a normal test, and writes neither on an agent or Workbench run + * - compositeReport blank/false => do not write the composite report + * - compositeReport true => write the composite report to the current default path + * - compositeReport other non-blank value => write the composite report to that path/template + * - scenarioReport blank/false => do not write scenario reports * - scenarioReport true => write scenario reports to reports/.html * - scenarioReport other non-blank value => write one report per scenario to that path/template * @@ -318,13 +318,8 @@ private static ReportOutputs resolveReportOutputs(Path defaultOutFile, Instant r ReportPathSetting compositeSetting = reportPathSetting("compositeReport", fallback.toString()); ReportPathSetting scenarioSetting = reportPathSetting("scenarioReport", "reports/"); - boolean scenarioReport = scenarioSetting.enabled(); - - boolean compositeReport = compositeSetting.enabled(); - if (compositeSetting.undefined()) { - // Default composite output is only automatic when scenario output was not explicitly enabled. - compositeReport = !scenarioReport; - } + boolean scenarioReport = AgentCoordination.htmlEnabled("scenarioReport"); + boolean compositeReport = AgentCoordination.htmlEnabled("compositeReport"); Path compositeOutFile = null; if (compositeReport) { @@ -335,7 +330,7 @@ private static ReportOutputs resolveReportOutputs(Path defaultOutFile, Instant r String compositeSource; if (compositeSetting.undefined()) { - compositeSource = compositeReport ? "default" : "default suppressed by scenarioReport"; + compositeSource = compositeReport ? "default" : "private-run default"; } else if (compositeSetting.blank()) { compositeSource = "RunVars:compositeReport(blank/false)"; } else if (compositeSetting.booleanTrue()) { @@ -347,7 +342,7 @@ private static ReportOutputs resolveReportOutputs(Path defaultOutFile, Instant r String scenarioTemplate = scenarioReport ? scenarioSetting.template() : null; String scenarioSource; if (scenarioSetting.undefined()) { - scenarioSource = "missing"; + scenarioSource = scenarioReport ? "default" : "private-run default"; } else if (scenarioSetting.blank()) { scenarioSource = "RunVars:scenarioReport(blank/false)"; } else if (scenarioSetting.booleanTrue()) { @@ -420,20 +415,16 @@ private static int intVar(String name, int defaultValue) { * blank and false disable the report; true enables it with the supplied default template/path. */ private static ReportPathSetting reportPathSetting(String name, String defaultTemplate) { - String sentinel = "__SIMPLE_HTML_REPORT_CONVERTER_UNDEFINED__" + name + "__"; - Object rawObject = resolveFromVarsOrDefault(name, sentinel); - String raw = Objects.toString(rawObject, sentinel); - - if (sentinel.equals(raw)) { + String explicit = AgentCoordination.explicitHtmlSetting(name); + if (explicit == null) { return new ReportPathSetting(name, null, null, true, false, false, false, defaultTemplate); } - String normalized = stripWrappingQuotes(raw.trim()); + String normalized = stripWrappingQuotes(explicit.trim()); boolean blank = normalized.isBlank(); boolean booleanTrue = "true".equalsIgnoreCase(normalized); boolean booleanFalse = blank || "false".equalsIgnoreCase(normalized); - - return new ReportPathSetting(name, raw, normalized, false, blank, booleanTrue, booleanFalse, defaultTemplate); + return new ReportPathSetting(name, explicit, normalized, false, blank, booleanTrue, booleanFalse, defaultTemplate); } private static String stripWrappingQuotes(String raw) { diff --git a/src/main/java/tools/dscode/testengine/PKB_props.java b/src/main/java/tools/dscode/testengine/PKB_props.java index e1e690c4d..b00ee7b22 100644 --- a/src/main/java/tools/dscode/testengine/PKB_props.java +++ b/src/main/java/tools/dscode/testengine/PKB_props.java @@ -45,6 +45,8 @@ public final class PKB_props { public static final String PKB_CUCUMBER_CLI_FEATURE_SELECTORS = PKB_PREFIX + "cucumber_cli_feature_selectors"; public static final String PKB_LOGLEVEL = PKB_PREFIX + "loglevel"; public static final String PKB_REPORTING_MODE = PKB_PREFIX + "reportingmode"; + public static final String PKB_COMPOSITE_REPORT = PKB_PREFIX + "compositereport"; + public static final String PKB_SCENARIO_REPORT = PKB_PREFIX + "scenarioreport"; public static final String PKB_REPORT_RETENTION = PKB_PREFIX + "reportretention"; public static final String PKB_DIAGNOSTIC_OUTPUT = PKB_PREFIX + "diagnostic_output"; public static final String PKB_PLATFORM_LOG = PKB_PREFIX + "platformlog"; diff --git a/src/main/java/tools/dscode/testengine/PickleballRunner.java b/src/main/java/tools/dscode/testengine/PickleballRunner.java index 447d9dd43..3051cbc38 100644 --- a/src/main/java/tools/dscode/testengine/PickleballRunner.java +++ b/src/main/java/tools/dscode/testengine/PickleballRunner.java @@ -133,14 +133,16 @@ protected PickleballRunner() { PickleballLocalLayout.findProjectRoot(java.nio.file.Path.of("")), System.out ); - values.put(PKB_props.PKB_RUN_ID, coordination.runId()); - values.put(PKB_props.PKB_AGENT_ID, coordination.agentId()); - if (coordination.group() != null) values.put(PKB_props.PKB_RUN_GROUP, coordination.group()); - if (coordination.sequence() != null) { - values.put(PKB_props.PKB_RUN_SEQUENCE, coordination.sequence().toString()); + if (coordination != null) { + values.put(PKB_props.PKB_RUN_ID, coordination.runId()); + values.put(PKB_props.PKB_AGENT_ID, coordination.agentId()); + if (coordination.group() != null) values.put(PKB_props.PKB_RUN_GROUP, coordination.group()); + if (coordination.sequence() != null) { + values.put(PKB_props.PKB_RUN_SEQUENCE, coordination.sequence().toString()); + } + if (coordination.who() != null) values.put(PKB_props.PKB_RUN_WHO, coordination.who()); + if (coordination.why() != null) values.put(PKB_props.PKB_RUN_WHY, coordination.why()); } - if (coordination.who() != null) values.put(PKB_props.PKB_RUN_WHO, coordination.who()); - if (coordination.why() != null) values.put(PKB_props.PKB_RUN_WHY, coordination.why()); } public static String getOptionsString() { @@ -275,7 +277,8 @@ public static PickleballRunner getInstance() { return current != null ? current : DynamicSuiteBootstrap.initializeFromRuntimeClasspath(); } - static PickleballRunner rawInstance() { return INSTANCE; } + /** The live runner, or null when no suite has started. Does not bootstrap one. */ + public static PickleballRunner rawInstance() { return INSTANCE; } private void mergeResourcePropertiesIfMissing(String resourceName) { try { diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index 08e2eb665..d70c3b486 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -10,6 +10,8 @@ Pickleball Workbench is the one front door. It is a Java program launched from t Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. + From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use that wrapper and the same exec invocation as the project pointer; only change `-Dexec.args`: ```text @@ -37,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. Reports, diagnostic packs, scratch, the browser profile, and a headless session file for that run stay under that directory. Two runs do not share an output folder, a browser profile, or a live session. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder or a live session. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. ### Live isolation loop @@ -168,6 +170,8 @@ pkb_parallel= pkb_reportingmode=diagnostic pkb_loglevel=warn pkb_reportretention=failed +pkb_compositeReport=false +pkb_scenarioReport=false ``` Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. 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 85c7702ae..efe03d0e4 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 @@ -51,4 +51,6 @@ The canonical worker bootstrap environment is `PKB_CONTROL_BRIDGE_SESSION_DIR`, The lightweight non-Spring stdio MCP adapter and player-style Swing/WebView UI both delegate through `WorkbenchServices` / `WorkbenchController`. `LiveScenarioPlayer` is a Workbench-side headless presentation model for the editable session buffer, stable line identities, selection, click-to-seek playhead, and player state only; it does not parse or execute Pickleball steps, model ParsingMap/NodeMap semantics, or claim runtime rewind. `LiveEditorView` is TEXT-only: there is no Blocks toggle or view mode. `GherkinTextEditing` owns colon-Tab indent and keyword autocomplete for that text editor. While `RUNNING`, `WorkbenchController.executeStep` owns playhead follow once (`LivePlaybackCoordinator.followExecutedStep`); the Swing Play loop continues without remaking that mark, and leftover marks of an already-consumed step are no-ops. The controller also owns the watched-agent control lease (`HUMAN` / `AGENT`, banner `currentAction`, gated Save permission). Consumer AI agents use Workbench `discover` / `confirm` as the one front door. When a Workbench window is already open, the agent drives that session with the same commands the window calls (`open-scenario`, `example`, `play`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations) so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, launcher `isolate` / `execute-step` / `status` / `events` / `stop` stay on the detached headless session (`cli-session.json`). UI mode may write a localhost attach endpoint to `.pickleball/workbench/attach.json` for that already-open window. Do not open the GUI for your own testing to obtain it. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. The left-rail picker filters project-owned scenarios by name (starts with / contains / ends with / full match; default contains; case-insensitive) and Cucumber tags (include AND, exclude NOT, Feature/Rule/outline/Examples inheritance parsed from catalog `.feature` files without calling Cucumber). Feature-file selection is an optional collapsed secondary filter; with none selected, name/tag apply to every catalog scenario. The live editor is ordinary Gherkin text over that same player model, with colon-Tab indent and keyword autocomplete. 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. Explorer replays retained `reports/diagnostic-runs` as an indented execution tree from `events.jsonl` (no Mermaid) and tails in-progress isolate `events.jsonl` / live `workbench_events`; picker Play replaces the live buffer, while Explorer/Report/`workbench_go` from a retained run peeks pack-local `source/files/` copies and never writes or replaces the live buffer. Unsaved isolate Gherkin that differs from disk is a Workbench sidecar that core copies into `source/live-buffer.feature`. Report is the fourth right tab over `investigation.json` (additive schemaVersion 2, v1 still renders) with Gherkin/business-first reading order. `workbench_go` is the one navigation MCP tool; UI attach plus a control lease are required to move the window. Workbench never checks out the consumer repo, never copies diagnostic packs into `.pickleball/investigations`, and never embeds PNG bytes in investigation JSON. The default buffer is a Workbench-owned browser demo against `URL.home`; **Save** is confirmation-gated and copies only a picker-loaded scenario back to its originating `.feature` file. OpenJFX is Workbench-only. MCP mode reserves stdout for newline-delimited MCP JSON-RPC and redirects ordinary output to stderr. The UI remains execution-oriented and does not recreate a project IDE, generic build/process UI, source navigator, or collaboration system. +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. + 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/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index e39388551..21eb0454c 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -308,7 +308,7 @@ Example compact rerun: For an agent's bounded confirmation, run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. This is not the `PickleballTests` human defaults of `pretty` / `@all`. Include diagnostic evidence controls, headless Chrome, and high parallelism when more than one scenario will run: ```text --Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_parallel=auto, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed" +-Dpkb_runvars="pkb_tags=@the-failing-tag, pkb_name=The failing scenario, pkb_browser=CHROME_HEADLESS, pkb_parallel=auto, pkb_reportingmode=diagnostic, pkb_loglevel=warn, pkb_reportretention=failed, pkb_compositeReport=false, pkb_scenarioReport=false" ``` Lineage metadata is not execution configuration: @@ -440,6 +440,8 @@ pkb_parallel= pkb_reportingmode=diagnostic pkb_loglevel=warn pkb_reportretention=failed +pkb_compositeReport=false +pkb_scenarioReport=false ``` plus the narrowest useful `pkb_tags` / `pkb_name` / `pkb_example`. Those three narrow which scenarios and Examples rows run. They are not a second player. `example` and `pkb_example` are the same Examples-row filter. A normal Scenario counts as row 1. `--example='1 2 5 3.4 7-11'` keeps those rows in each scenario that tags and name already selected. `pkb_example` is not a tag. Multi-scenario Discover/Confirm use that high parallelism. When nobody is watching, live isolate stays one paused scenario (`pkb_parallel=1`) on a headless session started with `isolate`. When a Workbench window is already open, the same session commands drive that window. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index 03a6a51f4..c792d4e82 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -110,9 +110,13 @@ Lineage and coordination metadata survive controlled execution but are excluded Different consumer agents can run different tests at the same time. The shared files under the consumer `.pickleball` directory are a bulletin board. Run data stays private to each run. -Every discover, confirm, isolate, and normal consumer run gets a unique run id when the caller does not pass one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. +A private `.pickleball/runs//` directory is only for an agent run or a Workbench run (`-Dpkb_run_id`, `-Dpkb_agent_id`, or a Workbench worker). A normal `mvn test` does not open that folder, does not set Chrome or Edge `user-data-dir`, and writes the composite HTML report to `reports/cucumber-report.html`. Diagnostic packs and scratch stay on their 2.1.13 paths for a normal test. The short log and inbox stay where they are. -Reports, diagnostic packs, scratch, the local browser profile, and a headless Workbench session file for that run live under `.pickleball/runs//`. Two runs never write the same output folder, the same browser profile, or the same live session file. +An agent or Workbench run that does not pass a run id gets one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. + +Reports, diagnostic packs, scratch, and a headless Workbench session file for that private run live under `.pickleball/runs//`. Local Chrome and Edge in one run do not share one profile. Each parallel worker gets `browser-profile/`. Remote drivers are unchanged. Options that already contain `user-data-dir` are left alone. Two runs never write the same output folder or the same live session file. + +`pkb_compositeReport=false` suppresses the composite `reports/cucumber-report.html`. `pkb_scenarioReport=false` suppresses the per-scenario workbook HTML that `Log.closeAll` writes. A normal test leaves both unset and still writes both reports. An agent run, and a Workbench run including a person collaborating with an agent, default both to false. Agents keep diagnostic logs. An explicit `true` writes that report again. `pkb_reportingmode=diagnostic` still bypasses automatic HTML, ReportPortal, and XLSX unless one of those HTML run vars is explicitly true. The run record is `.pickleball/runs//record.json`: run id, agent id, optional group, sequence, who, why, what was learned, start time, stop time, status, and the path to the run data. The short log only points at that record. @@ -394,6 +398,8 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_parallel` | `4`, `auto` | parallel scenario count; `auto` resolves at run start to a conservative JVM estimate and stamps the integer into `pkb_run_profile` | | `pkb_loglevel` | `debug` | console log level | | `pkb_reportingmode` | `diagnostic` | diagnostic evidence pipeline | +| `pkb_compositeReport` | `true`, `false` | composite `reports/cucumber-report.html`; unset writes it on a normal test and suppresses it on an agent or Workbench run | +| `pkb_scenarioReport` | `true`, `false` | per-scenario workbook HTML; same default as `pkb_compositeReport` | | `pkb_reportretention` | `all`, `failed`, `none` | automatic evidence/report retention | | `pkb_diagnostic_output` | `reports/diagnostic-runs` | optional diagnostic output root | | `pkb_platformlog` | `default`, `default+git`, `none`, etc. | platform/caller log stamps | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index 08e2eb665..d70c3b486 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -10,6 +10,8 @@ Pickleball Workbench is the one front door. It is a Java program launched from t Run the project's own wrapper. Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. On Windows use `mvnw.cmd` or `gradlew.bat`. Otherwise use `mvnw` or `gradlew`. If that script is absent and the wrapper jar is present, run `java -jar` on `.mvn/wrapper/maven-wrapper.jar` or `gradle/wrapper/gradle-wrapper.jar`. Discover, confirm, and project sync already invoke that wrapper. MCP and launcher tools stay Pickleball actions (`discover`, `confirm`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `pkb_example`, and the other session commands). There is no general Maven or Gradle tool. +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. + From the consumer project, with Pickleball on the test classpath (`classpathScope=test`), use that wrapper and the same exec invocation as the project pointer; only change `-Dexec.args`: ```text @@ -37,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. Reports, diagnostic packs, scratch, the browser profile, and a headless session file for that run stay under that directory. Two runs do not share an output folder, a browser profile, or a live session. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder or a live session. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. ### Live isolation loop @@ -168,6 +170,8 @@ pkb_parallel= pkb_reportingmode=diagnostic pkb_loglevel=warn pkb_reportretention=failed +pkb_compositeReport=false +pkb_scenarioReport=false ``` Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. diff --git a/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java index f6e7407a9..7bb394dae 100644 --- a/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java +++ b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java @@ -1,10 +1,18 @@ package tools.dscode.common.coordination; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.edge.EdgeOptions; import tools.dscode.common.driver.DriverConstruction; +import tools.dscode.common.reporting.diagnostic.DiagnosticRuntime; +import tools.dscode.common.reporting.diagnostic.ReportRetentionPolicy; +import tools.dscode.common.reporting.logging.Entry; +import tools.dscode.common.reporting.logging.simplehtml.SimpleHtmlReportConverter; +import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.testengine.PKB_props; import java.nio.charset.StandardCharsets; @@ -18,10 +26,12 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -32,6 +42,9 @@ class AgentCoordinationTest { @AfterEach void clearCoordination() { AgentCoordination.clearCurrent(); + clearLaunchSignals(); + ReportRetentionPolicy.clearThreadOverride(); + DiagnosticRuntime.DIAGNOSTIC_MODE = false; } @Test @@ -208,7 +221,10 @@ void localBrowserProfileUsesTheCurrentRunAndDoesNotReplaceAnExistingDirectory() AgentCoordination.Run run = AgentCoordination.begin(project, request("run-browser", "agent-browser")); ChromeOptions local = new ChromeOptions(); DriverConstruction.applyRunBrowserProfile(local); - assertTrue(argumentText(local).contains("--user-data-dir=" + run.browserProfileDirectory().toAbsolutePath().normalize())); + Path workerProfile = AgentCoordination.currentBrowserProfile(); + assertTrue(workerProfile.startsWith(run.browserProfileDirectory())); + assertNotEquals(run.browserProfileDirectory(), workerProfile); + assertTrue(argumentText(local).contains("--user-data-dir=" + workerProfile.toAbsolutePath().normalize())); ChromeOptions preset = new ChromeOptions(); preset.addArguments("--user-data-dir=/tmp/already-mine"); @@ -219,6 +235,132 @@ void localBrowserProfileUsesTheCurrentRunAndDoesNotReplaceAnExistingDirectory() assertEquals(1, text.split("user-data-dir", -1).length - 1); } + @Test + void normalTestCreatesNoRunDirectoryAndSetsNoBrowserProfile() { + clearLaunchSignals(); + assertNull(AgentCoordination.openConsumerRun(project, new StringBuilder())); + assertFalse(Files.exists(project.resolve(".pickleball/runs"))); + ChromeOptions chrome = new ChromeOptions(); + EdgeOptions edge = new EdgeOptions(); + DriverConstruction.applyRunBrowserProfile(chrome); + DriverConstruction.applyRunBrowserProfile(edge); + assertFalse(argumentText(chrome).contains("user-data-dir")); + assertFalse(argumentText(edge).contains("user-data-dir")); + assertEquals(Path.of("reports", "cucumber-report.html"), AgentCoordination.reportHtmlOrDefault()); + assertTrue(AgentCoordination.htmlEnabled("compositeReport")); + assertTrue(AgentCoordination.htmlEnabled("scenarioReport")); + } + + @Test + void parallelLocalChromeWorkersGetSeparateProfilesAndRemoteStaysUntouched() throws Exception { + AgentCoordination.Run run = AgentCoordination.begin(project, request("run-workers", "agent-workers")); + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch go = new CountDownLatch(1); + List profiles = new ArrayList<>(); + try { + Future left = pool.submit(() -> captureProfile(profiles, ready, go)); + Future right = pool.submit(() -> captureProfile(profiles, ready, go)); + ready.await(); + go.countDown(); + left.get(); + right.get(); + } finally { + pool.shutdownNow(); + } + assertEquals(2, profiles.size()); + assertNotEquals(profiles.get(0), profiles.get(1)); + String root = run.browserProfileDirectory().toAbsolutePath().normalize().toString(); + for (String profile : profiles) { + assertTrue(profile.contains("--user-data-dir=" + root)); + assertTrue(profile.contains("browser-profile")); + assertFalse(profile.endsWith("browser-profile") || profile.endsWith("browser-profile\"")); + } + + ObjectNode empty = JsonNodeFactory.instance.objectNode(); + ChromeOptions remote = DriverConstruction.buildChromeOptions(empty, empty, false); + assertFalse(argumentText(remote).contains("user-data-dir")); + } + + @Test + void agentAndWorkbenchRunsSuppressHtmlUntilAnExplicitTrue() throws Exception { + ReportRetentionPolicy.configure("all"); + clearLaunchSignals(); + Path normalReport = project.resolve("reports").resolve("cucumber-report.html"); + recordScenario(normalReport, "Normal checkout"); + assertTrue(Files.isRegularFile(normalReport)); + assertTrue(htmlCount(project.resolve("reports")) >= 2); + + System.setProperty(PKB_props.PKB_RUN_ID, "run-html"); + System.setProperty(PKB_props.PKB_AGENT_ID, "agent-html"); + AgentCoordination.Run agent = AgentCoordination.openConsumerRun(project, new StringBuilder()); + assertTrue(Files.isDirectory(project.resolve(".pickleball").resolve("runs").resolve("run-html"))); + assertFalse(AgentCoordination.htmlEnabled("compositeReport")); + assertFalse(AgentCoordination.htmlEnabled("scenarioReport")); + DiagnosticRuntime.DIAGNOSTIC_MODE = true; + Path agentReport = agent.reportsDirectory().resolve("cucumber-report.html"); + recordScenario(agentReport, "Agent checkout"); + assertFalse(Files.exists(agentReport)); + assertEquals(0, htmlCount(agent.reportsDirectory())); + + System.setProperty(PKB_props.PKB_COMPOSITE_REPORT, "true"); + System.setProperty(PKB_props.PKB_SCENARIO_REPORT, "true"); + assertTrue(AgentCoordination.htmlEnabled("compositeReport")); + assertTrue(AgentCoordination.htmlEnabled("scenarioReport")); + recordScenario(agentReport, "Agent checkout again"); + assertTrue(Files.isRegularFile(agentReport)); + assertTrue(htmlCount(agent.reportsDirectory()) >= 2); + + AgentCoordination.clearCurrent(); + clearLaunchSignals(); + System.setProperty(ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY, project.resolve("live").toString()); + AgentCoordination.Run workbench = AgentCoordination.openConsumerRun(project, new StringBuilder()); + assertFalse(AgentCoordination.htmlEnabled("compositeReport")); + assertFalse(AgentCoordination.htmlEnabled("scenarioReport")); + Path workbenchReport = workbench.reportsDirectory().resolve("cucumber-report.html"); + recordScenario(workbenchReport, "Workbench checkout"); + assertFalse(Files.exists(workbenchReport)); + } + + private void captureProfile(List profiles, CountDownLatch ready, CountDownLatch go) { + ready.countDown(); + try { + go.await(); + ChromeOptions options = new ChromeOptions(); + DriverConstruction.applyRunBrowserProfile(options); + synchronized (profiles) { + profiles.add(argumentText(options)); + } + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + + private static void recordScenario(Path compositeReport, String title) { + SimpleHtmlReportConverter.resetRun(); + SimpleHtmlReportConverter converter = new SimpleHtmlReportConverter(compositeReport); + Entry scope = Entry.of(title); + converter.onStart(scope, scope); + converter.onStop(scope, scope); + converter.close(); + SimpleHtmlReportConverter.writeFinalReport(compositeReport); + } + + private static long htmlCount(Path directory) throws Exception { + if (!Files.isDirectory(directory)) return 0; + try (Stream files = Files.list(directory)) { + return files.filter(path -> path.getFileName().toString().endsWith(".html")).count(); + } + } + + private static void clearLaunchSignals() { + System.clearProperty(PKB_props.PKB_RUN_ID); + System.clearProperty(PKB_props.PKB_AGENT_ID); + System.clearProperty(PKB_props.PKB_COMPOSITE_REPORT); + System.clearProperty(PKB_props.PKB_SCENARIO_REPORT); + System.clearProperty(ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY); + } + private void appendMany(String agent, int count, CountDownLatch ready, CountDownLatch go) { ready.countDown(); try { @@ -243,8 +385,14 @@ private static AgentCoordination.Request request(String runId, String agentId) { return AgentCoordination.Request.of(runId, agentId, null, null, null, null, "test"); } - private static String argumentText(ChromeOptions options) { - Object raw = options.getCapability("goog:chromeOptions"); + private static String argumentText(org.openqa.selenium.chromium.ChromiumOptions options) { + String chrome = argumentsFrom(options, "goog:chromeOptions"); + String edge = argumentsFrom(options, "ms:edgeOptions"); + return chrome.isEmpty() ? edge : chrome; + } + + private static String argumentsFrom(org.openqa.selenium.chromium.ChromiumOptions options, String capability) { + Object raw = options.getCapability(capability); if (!(raw instanceof java.util.Map map)) return ""; Object args = map.get("args"); if (!(args instanceof List list)) return ""; diff --git a/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java b/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java index 46aff928e..dfa6a57a1 100644 --- a/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java +++ b/src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java @@ -31,6 +31,8 @@ void discoverRunVarsUseBrowserLadderAndEstimatedParallel() throws Exception { assertTrue(plan.runVars().contains("pkb_parallel=" + ParallelCountEstimator.estimate())); assertTrue(plan.runVars().contains("pkb_reportingmode=diagnostic")); assertTrue(plan.runVars().contains("pkb_reportretention=failed")); + assertTrue(plan.runVars().contains("pkb_compositereport=false")); + assertTrue(plan.runVars().contains("pkb_scenarioreport=false")); assertTrue(plan.runVars().contains("pkb_tags=@smoke")); assertFalse(plan.runVars().contains("pkb_parallel=80")); assertFalse(plan.runVars().contains("pkb_run_profile=")); diff --git a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java index d1efeee9b..450a872e8 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java @@ -92,6 +92,9 @@ void agentGuideSaysResolveRunVarsIsNotTheDiscoverEnvironment() throws Exception )) { String text = Files.readString(Path.of(path)); assertTrue(text.contains(line), path); + assertTrue(text.contains( + "The Workbench GUI is only a lightweight head over state and controls that already exist under the hood." + ), path); assertFalse(text.contains("pkb_runvarssealed"), path); assertTrue(text.contains("pkb_overriderunvars"), path); assertTrue(text.contains("Never supply `pkb_run_profile` as input") From a2a360adee2a9f0f2076f6b1b2ef5918a64045c4 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Fri, 2 Oct 2026 22:53:10 +0000 Subject: [PATCH 16/23] Bottom line: a conditional branch resolves mappings and expressions only when that branch is taken, and only once. --- .../example/pickleball/SyntaxProofSteps.java | 24 ++++ .../features/lazy-branch-resolution.feature | 57 +++++++++ .../core/runner/CurrentScenarioState.java | 24 ++++ .../treeparsing/parsedComponents/Phrase.java | 47 ++++++- .../parsedComponents/PhraseData.java | 120 ++++++++++++++++-- .../treeparsing/preparsing/LineData.java | 35 ++++- .../treeparsing/preparsing/ParsedLine.java | 3 + .../preparsing/LazyBranchResolutionTest.java | 71 +++++++++++ 8 files changed, 367 insertions(+), 14 deletions(-) create mode 100644 maven-consumer-project/src/test/resources/features/lazy-branch-resolution.feature create mode 100644 src/test/java/tools/dscode/common/treeparsing/preparsing/LazyBranchResolutionTest.java diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java index 1e4d76007..b6d0a9057 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java @@ -14,10 +14,34 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import static tools.dscode.common.mappings.MappingProcessor.getRunMap; public class SyntaxProofSteps { + private static final ConcurrentHashMap BRANCH_HITS = new ConcurrentHashMap<>(); + + @Given("^reset branch counts$") + public void resetBranchCounts() { + BRANCH_HITS.clear(); + } + + @Given("^count branch ([A-Za-z0-9-]+)$") + public static String countBranch(String name) { + return Integer.toString(BRANCH_HITS + .computeIfAbsent(name, key -> new AtomicInteger()) + .incrementAndGet()); + } + + @Given("^branch count \"([^\"]*)\" equals \"([^\"]*)\"$") + public void branchCountEquals(String name, String expected) { + String actual = Integer.toString(BRANCH_HITS.getOrDefault(name, new AtomicInteger()).get()); + if (!actual.equals(expected)) { + throw new AssertionError("branch " + name + " count was " + actual + " not " + expected); + } + } + @Given("^the recorded city is saved as \"([^\"]*)\"$") public void recordCity(String key, DataTable table) { getRunMap().put(key, cityCell(table)); diff --git a/maven-consumer-project/src/test/resources/features/lazy-branch-resolution.feature b/maven-consumer-project/src/test/resources/features/lazy-branch-resolution.feature new file mode 100644 index 000000000..558c5d391 --- /dev/null +++ b/maven-consumer-project/src/test/resources/features/lazy-branch-resolution.feature @@ -0,0 +1,57 @@ +@all @lazy-branch-resolution +Feature: Conditional branches resolve references only when taken + + Scenario: A skipped THEN does not call DateTime or resolve an unresolved reference + * reset branch counts + * , save "alive" as "afterSkippedThen" + * IF: 1 == 0 THEN: save "<$DateTime:now format: uuuu>" + * IF: false THEN: save "<$count branch skipped-then>" + * , ensure "" equals "alive" + * branch count "skipped-then" equals "0" + + Scenario: A true IF does not evaluate a later ELSE-IF with an unresolved reference + * , save "6" as "A" + * IF:
> 5 THEN: , save "a" as "picked" ELSE-IF: > 5 THEN: , save "b" as "picked" + * , ensure "" equals "a" + + Scenario: An expression side effect runs only on the taken branch and only once + * reset branch counts + * IF: 1 == 0 THEN: , save "<{ <$count branch expr-skipped> }>" as "exprSkipped" ELSE: , save "<{ <$count branch expr-taken> }>" as "exprTaken" + * , ensure "" equals "1" + * branch count "expr-taken" equals "1" + * branch count "expr-skipped" equals "0" + + Scenario: A multi-step block resolves only the chosen branch + * reset branch counts + * IF: 1 == 1: + : * , save "if-ran" as "blockBranch" + : * , save "<$count branch block-if>" as "blockIfCount" + * ELSE-IF: > 5: + : * , save "<$DateTime:now format: uuuu>" as "blockNope" + : * , save "<$count branch block-else-if>" as "blockElseIfCount" + * ELSE: + : * , save "<$count branch block-else>" as "blockElseCount" + * , ensure "" equals "if-ran" + * , ensure "" equals "1" + * branch count "block-if" equals "1" + * branch count "block-else-if" equals "0" + * branch count "block-else" equals "0" + + Scenario: A false multi-step block does not resolve the skipped steps + * reset branch counts + * IF: 1 == 0: + : * , save "<$DateTime:now format: uuuu>" as "skippedBlockStamp" + : * , save "<$count branch block-skipped>" as "skippedBlockCount" + * ELSE: + : * , save "else-ran" as "falseBlockBranch" + * , ensure "" equals "else-ran" + * branch count "block-skipped" equals "0" + + Scenario: A dynamic-step inline conditional uses the same timing + * reset branch counts + * , save "6" as "A" + * , if > 5, save "a" as "dynPicked", else if > 5, save "b" as "dynPicked" + * , ensure "" equals "a" + * , if 1 == 0, save "<$DateTime:now format: uuuu>" as "dynNope", else, save "<{ <$count branch comma-else> }>" as "commaElse" + * , ensure "" equals "1" + * branch count "comma-else" equals "1" diff --git a/src/main/java/io/cucumber/core/runner/CurrentScenarioState.java b/src/main/java/io/cucumber/core/runner/CurrentScenarioState.java index 6df671f07..431709e8f 100644 --- a/src/main/java/io/cucumber/core/runner/CurrentScenarioState.java +++ b/src/main/java/io/cucumber/core/runner/CurrentScenarioState.java @@ -437,6 +437,12 @@ public void runStep(StepExtension stepExtension) { if (endCurrentScenario) return; + if (isUnselectedBlockChild(stepExtension)) { + stepExtension.skipped = true; + logSkip("Skipping Step: " + stepExtension.pickleStepTestStep.getStepText()); + runNextSibling(stepExtension); + return; + } currentStep = stepExtension; if (currentStep.startTime == null) currentStep.startTime = Instant.now(); @@ -605,6 +611,24 @@ public void runningStep(StepExtension stepExtension) { runNextSibling(stepExtension); } + /** + * A child of a block conditional whose branch was not taken must not be + * parsed. IGNORE_CHILDREN_IF_FALSE already skips running it; parsing would + * still resolve mappings, expressions, and $ functions. + */ + private boolean isUnselectedBlockChild(StepExtension stepExtension) { + StepBase parent = stepExtension.parentStep; + while (parent != null) { + if (parent.definitionFlags.contains(IGNORE_CHILDREN_IF_FALSE) + && parent.lineData != null + && parent.lineData.lineConditionalMode < 1) { + return true; + } + parent = parent.parentStep; + } + return false; + } + /** * Finalizers belong to the completed step but are not part of its ordinary * child/sibling chain. They run after attached/child work finishes and before diff --git a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java index d9ec6a8e6..51590ec67 100644 --- a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java +++ b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java @@ -36,10 +36,23 @@ public Phrase(LineData parsedLine) { isTopContext = true; } public Phrase(String inputText, Character delimiter, LineData parsedLine) { - super(inputText, delimiter, parsedLine, null); + this(inputText, delimiter, parsedLine, null, true); } public Phrase(String inputText, Character delimiter, LineData parsedLine, PhraseData previousPhrase) { - super(inputText, delimiter, parsedLine, previousPhrase); + this(inputText, delimiter, parsedLine, previousPhrase, true); + } + public Phrase( + String inputText, + Character delimiter, + LineData parsedLine, + PhraseData previousPhrase, + boolean resolveEvaluations + ) { + super(inputText, delimiter, parsedLine, previousPhrase, resolveEvaluations); + } + + @Override + protected void afterParse() { if (!isOperationPhrase) { elementMatches = new ArrayList<>(elementMatches.stream().filter(e -> !e.isPlaceHolder()).toList()); } @@ -133,6 +146,9 @@ public void executePhrase() { } StepExtension currentStep = getRunningStep(); if (shouldRun()) { + if (deferEvaluations && !evaluationsResolved && !suppressResolve && assertionChain == null) { + resolveForExecution(); + } if (assertionChain == null) { phraseEntry = currentStep.stepEntry.logWithType("PHRASE", toString(), currentStep.stepLogLevel).tags("phrase").start(); logToDefaultLevel("Running Phrase: " + this.resolvedText); @@ -322,6 +338,14 @@ public PhraseData clonePhrase(PhraseData previous, Character newTermination) { return clone; } public PhraseData resolvePhrase() { + if (deferEvaluations || evaluationsResolved) { + // Construction already resolved an ordinary phrase, or a conditional + // phrase resolves once later in executePhrase. Do not evaluate again. + return this; + } + if (getResolvedPhrase() != null) { + return getResolvedPhrase(); + } PhraseData resolvedPhrase = new Phrase(originalText, termination, parsedLine, getPreviousPhrase()); setResolvedPhrase(resolvedPhrase); getResolvedPhrase().position = position; @@ -333,7 +357,14 @@ public PhraseData resolvePhrase() { } public PhraseData getNextResolvedPhrase() { if (getNextPhrase() == null) return null; - PhraseData nextResolvedPhrase = getNextPhrase().resolvePhrase(); + PhraseData next = getNextPhrase(); + if (next.deferEvaluations) { + if (!next.shouldResolveBranchReferences()) { + next.suppressResolve = true; + } + return next; + } + PhraseData nextResolvedPhrase = next.resolvePhrase(); nextResolvedPhrase.setPreviousPhrase(this); this.setNextPhrase(nextResolvedPhrase); return nextResolvedPhrase; @@ -343,7 +374,15 @@ public static Phrase copyPhraseWithModifications(Phrase phrase) { return copyPhraseWithModifications(phrase, null, null, null); } public static Phrase copyPhraseWithModifications(Phrase phrase, Character newTermination, LineData parsedLine, PhraseData previous) { - Phrase clonePhrase = newTermination == null ? new Phrase(phrase.originalText, phrase.termination, phrase.parsedLine) : new Phrase(phrase.originalText, newTermination, parsedLine, previous); + Phrase clonePhrase; + if (phrase.deferEvaluations) { + // A clone of a conditional phrase stays structural until that clone executes. + clonePhrase = newTermination == null + ? new Phrase(phrase.originalText, phrase.termination, phrase.parsedLine, null, false) + : new Phrase(phrase.originalText, newTermination, parsedLine, previous, false); + } else { + clonePhrase = newTermination == null ? new Phrase(phrase.originalText, phrase.termination, phrase.parsedLine) : new Phrase(phrase.originalText, newTermination, parsedLine, previous); + } clonePhrase.operationInheritancePhrase = phrase.operationInheritancePhrase; clonePhrase.hasNo = phrase.hasNo; clonePhrase.assertionChainMembership = phrase.assertionChainMembership; diff --git a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java index 19b74108c..b9cf0a79d 100644 --- a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java +++ b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java @@ -55,7 +55,7 @@ public abstract class PhraseData extends PassedData { public String originalText; public String metaTextPrefix; public final String text; - public final String resolvedText; + public String resolvedText; public Character termination; // nullable public final LineData parsedLine; private SearchContext searchContext; @@ -128,6 +128,13 @@ public String getText() { public boolean hasResolvedText = false; public boolean hasTextToResolve = false; + /** + * Structural conditional phrases are parsed without evaluations. + * References are resolved once, when the phrase is about to execute. + */ + public boolean deferEvaluations; + public boolean evaluationsResolved; + public boolean suppressResolve; public ParsingMap getPhraseParsingMap() { if (phraseParsingMap == null) { @@ -168,7 +175,7 @@ public void setPhraseParsingMap(JsonNode data) { } public PhraseData(String inputText, Character delimiter, LineData lineData) { - this(inputText, delimiter, lineData, null); + this(inputText, delimiter, lineData, null, true); } public final boolean defaultContextPhrase; @@ -178,6 +185,16 @@ public boolean isContextTermination() { } public PhraseData(String inputText, Character delimiter, LineData lineData, PhraseData previousPhrase) { + this(inputText, delimiter, lineData, previousPhrase, true); + } + + public PhraseData( + String inputText, + Character delimiter, + LineData lineData, + PhraseData previousPhrase, + boolean resolveEvaluations + ) { originalText = inputText; var m = Pattern.compile(META_TEXT_SEPARATOR + "\\s*(.*?)\\s*" + META_TEXT_SEPARATOR).matcher(inputText); boolean found = m.find(); @@ -190,11 +207,97 @@ public PhraseData(String inputText, Character delimiter, LineData lineData, Phra setPreviousPhrase(previousPhrase); parsedLine = lineData; text = inputText; - resolvedText = resolveText(text); + deferEvaluations = !resolveEvaluations; + if (resolveEvaluations) { + resolvedText = resolveText(text); + evaluationsResolved = true; + } else { + // Structural pass only. Do not resolve mappings, expressions, or $ functions. + resolvedText = text; + evaluationsResolved = false; + } hasResolvedText = !text.trim().equalsIgnoreCase(resolvedText.trim()); hasTextToResolve = hasResolvedText || text.matches(".*<.*>.*"); termination = delimiter; - MatchNode returnMatchNode = getNodeDictionary().parse(resolvedText); + parseFromText(resolvedText); + position = lineData.phrases.size(); + } + + /** + * One-shot execution resolve. A skipped branch never calls this. + * A taken phrase calls it once, then executes the resolved structure. + */ + public void resolveForExecution() { + if (evaluationsResolved || suppressResolve) { + return; + } + resolvedText = resolveText(text); + evaluationsResolved = true; + hasResolvedText = !text.trim().equalsIgnoreCase(resolvedText.trim()); + hasTextToResolve = hasResolvedText || text.matches(".*<.*>.*"); + resetParsedFields(); + parseFromText(resolvedText); + } + + /** + * False when this deferred phrase is a later branch or the body of a + * condition that was not taken. The condition itself is resolved only + * when no earlier branch was taken. + */ + public boolean shouldResolveBranchReferences() { + if (!deferEvaluations || evaluationsResolved) { + return true; + } + if (suppressResolve) { + return false; + } + return !skipReferenceResolution(); + } + + public boolean skipReferenceResolution() { + String cond = getConditional() == null ? "" : getConditional().trim().toLowerCase(); + if (cond.startsWith("else")) { + if (position == 0 || getPreviousPhrase() == null) { + return parsedLine.previousSiblingConditionalState > -1; + } + return getPreviousPhrase().phraseConditionalMode > -1; + } + PhraseData previous = getPreviousPhrase(); + if (previous != null + && previous.phraseConditionalMode <= 0 + && !cond.equals("if") + && !cond.equals("until")) { + return true; + } + return false; + } + + private void resetParsedFields() { + untilPhrase = false; + booleanValues = List.of(); + conditional = ""; + assertion = ""; + assertionType = ""; + action = ""; + actionOperation = null; + assertionOperation = null; + isOperationPhrase = false; + phraseType = null; + hasNo = false; + body = ""; + separator = false; + conjunction = ""; + context = ""; + isFrom = false; + isTopContext = false; + isPageContext = false; + operationIndex = 0; + setNewContext(false); + setElementMatches(new ArrayList<>()); + } + + private void parseFromText(String source) { + MatchNode returnMatchNode = getNodeDictionary().parse(source); phraseNode = returnMatchNode.getChild("phrase"); assert phraseNode != null; booleanValues = phraseNode.getOrderedChildren("booleanValue").stream().map(ValueWrapper::createValueWrapper).toList(); @@ -216,16 +319,11 @@ public PhraseData(String inputText, Character delimiter, LineData lineData, Phra conjunction = phraseNode.getStringFromLocalState("conjunction"); - position = lineData.phrases.size(); context = phraseNode.getStringFromLocalState("context"); if (getConditional().contains("if")) { setAssertionType("conditional"); setAssertion(phraseNode.getStringFromLocalState("assertion")); } -// else if (termination.equals('?')) { -// setAssertionType("conditionalTermination"); -// setAssertion(phraseNode.getStringFromLocalState("assertion")); -// } else if (!context.isBlank()) { phraseType = PhraseType.CONTEXT; isFrom = context.equals("from"); @@ -249,6 +347,10 @@ else if (!context.isBlank()) { if (phraseType == null && !elementMatches.isEmpty()) { phraseType = ELEMENT_ONLY; } + afterParse(); + } + + protected void afterParse() { } public ElementMatch getElementMatch(MatchNode elementNode) { diff --git a/src/main/java/tools/dscode/common/treeparsing/preparsing/LineData.java b/src/main/java/tools/dscode/common/treeparsing/preparsing/LineData.java index 0a3efea4f..7943072b2 100644 --- a/src/main/java/tools/dscode/common/treeparsing/preparsing/LineData.java +++ b/src/main/java/tools/dscode/common/treeparsing/preparsing/LineData.java @@ -14,6 +14,7 @@ import static io.cucumber.core.runner.StepBase.getInheritancePhrase; import static tools.dscode.common.GlobalConstants.BOOK_END; +import static tools.dscode.common.GlobalConstants.META_TEXT_SEPARATOR; import static tools.dscode.common.treeparsing.RegexUtil.stripObscureNonText; public abstract class LineData implements Cloneable { @@ -37,6 +38,7 @@ public abstract class LineData implements Cloneable { public int inheritedConditionalState; public int previousSiblingConditionalState = 1; public StepBase stepExtension; + private boolean deferConditionalChain; public void setInheritance(StepBase currentStep) { @@ -163,7 +165,11 @@ private static boolean containsConditionalOperator(String masked) { public void addPhrase(String phraseText, char termination, int lineComponentIndex) { String phraseString = phraseText.replace(BOOK_END, "") + termination; if (lineComponentIndex == 0) { - phrases.add(new Phrase(phraseText, termination, this)); + boolean defer = deferConditionalChain || opensConditionalChain(phraseText); + phrases.add(new Phrase(phraseText, termination, this, null, !defer)); + if (defer) { + deferConditionalChain = true; + } runningText += phraseString; } else { if (lineComponents.size() < lineComponentIndex) { @@ -177,6 +183,33 @@ public void addPhrase(String phraseText, char termination, int lineComponentInde } } + /** + * A conditional phrase, and every later comma phrase in that sentence, + * is constructed without evaluating mappings, expressions, or $ functions. + */ + static boolean opensConditionalChain(String phraseText) { + String stripped = stripConditionalMeta(phraseText); + return stripped.matches("(?is)^(?:(?:and|or|the)\\b\\s*)*(?:then\\b\\s*)?(?:else\\s+if|else|if|until)\\b.*") + || stripped.matches("(?is)^then\\b.*"); + } + + private static String stripConditionalMeta(String phraseText) { + if (phraseText == null || phraseText.isBlank()) { + return ""; + } + String stripped = phraseText; + int start = stripped.indexOf(META_TEXT_SEPARATOR); + while (start >= 0) { + int end = stripped.indexOf(META_TEXT_SEPARATOR, start + META_TEXT_SEPARATOR.length()); + if (end < 0) { + break; + } + stripped = stripped.substring(0, start) + stripped.substring(end + META_TEXT_SEPARATOR.length()); + start = stripped.indexOf(META_TEXT_SEPARATOR); + } + return stripped.trim(); + } + /** * Original, unmodified input. */ diff --git a/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java b/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java index e2ff927fe..ff19f0f2f 100644 --- a/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java +++ b/src/main/java/tools/dscode/common/treeparsing/preparsing/ParsedLine.java @@ -169,6 +169,9 @@ public static List splitKeepingTokens(String input) { @Override public PhraseData runPhraseFromLine(PhraseData phrase) { // setDefaultEntry(getRunningStep().stepEntry); + if (phrase.deferEvaluations && !phrase.shouldResolveBranchReferences()) { + phrase.suppressResolve = true; + } phrase.setOperationInheritanceIfNeeded(); if (!phrase.getAssertion().isBlank() && phrase.assertionChainMembership == null && phrase.assertionChain == null) { diff --git a/src/test/java/tools/dscode/common/treeparsing/preparsing/LazyBranchResolutionTest.java b/src/test/java/tools/dscode/common/treeparsing/preparsing/LazyBranchResolutionTest.java new file mode 100644 index 000000000..e31105d7c --- /dev/null +++ b/src/test/java/tools/dscode/common/treeparsing/preparsing/LazyBranchResolutionTest.java @@ -0,0 +1,71 @@ +package tools.dscode.common.treeparsing.preparsing; + +import org.junit.jupiter.api.Test; +import tools.dscode.common.treeparsing.parsedComponents.PhraseData; + +import java.lang.reflect.Constructor; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LazyBranchResolutionTest { + + @Test + void skippedThenAndLaterElseIfStayUnresolvedAtConstruction() throws Exception { + ParsedLine skippedThen = parse(ParsedLine.rewriteConditionalBranches( + "IF: \"\" THEN: save \"<$DateTime:now to stamp>\"")); + String thenText = phraseText(skippedThen); + assertTrue(thenText.contains("$DateTime"), thenText); + assertTrue(thenText.contains(""), thenText); + assertTrue(skippedThen.phrases().stream().allMatch(phrase -> phrase.deferEvaluations)); + assertTrue(skippedThen.phrases().stream().noneMatch(phrase -> phrase.evaluationsResolved)); + + ParsedLine laterElseIf = parse(ParsedLine.rewriteConditionalBranches( + "IF: > 5 THEN: , save \"a\" ELSE-IF: > 5 THEN: , save \"b\"")); + String elseIfText = phraseText(laterElseIf); + assertTrue(elseIfText.contains("<{"), elseIfText); + assertTrue(elseIfText.contains(""), elseIfText); + assertFalse(elseIfText.contains("DateTime")); + PhraseData ifPhrase = laterElseIf.phrases().getFirst(); + PhraseData thenPhrase = laterElseIf.phrases().get(1); + PhraseData elseIf = laterElseIf.phrases().stream() + .filter(phrase -> phrase.getConditional() != null && phrase.getConditional().startsWith("else")) + .findFirst() + .orElseThrow(); + assertTrue(elseIf.getText().contains("<{"), elseIf.getText()); + assertTrue(elseIf.getText().contains(""), elseIf.getText()); + ifPhrase.evaluationsResolved = true; + ifPhrase.phraseConditionalMode = 1; + thenPhrase.phraseConditionalMode = 1; + assertTrue(elseIf.skipReferenceResolution(), elseIf.getConditional()); + assertFalse(elseIf.shouldResolveBranchReferences()); + + ifPhrase.phraseConditionalMode = -1; + thenPhrase.phraseConditionalMode = -1; + assertFalse(elseIf.skipReferenceResolution(), elseIf.getConditional()); + assertTrue(elseIf.shouldResolveBranchReferences()); + } + + @Test + void falseConditionDoesNotResolveItsRunStepBody() throws Exception { + ParsedLine line = parse(ParsedLine.rewriteConditionalBranches( + "IF: 1 == 0 THEN: save \"<$DateTime:now format: uuuu>\" ELSE: save \"kept\"")); + PhraseData condition = line.phrases().getFirst(); + condition.evaluationsResolved = true; + condition.phraseConditionalMode = -1; + PhraseData body = line.phrases().get(1); + assertTrue(body.getText().contains("$DateTime"), body.getText()); + assertTrue(body.skipReferenceResolution(), body.getConditional() + " / " + body.getText()); + assertFalse(body.evaluationsResolved); + } + + private static String phraseText(ParsedLine line) { + return line.phrases().stream().map(PhraseData::getText).reduce("", (left, right) -> left + " | " + right); + } + + private static ParsedLine parse(String input) throws Exception { + Constructor constructor = ParsedLine.class.getDeclaredConstructor(String.class); + constructor.setAccessible(true); + return constructor.newInstance(input); + } +} From 9464362076e4ea49732e3a782642c0d3410ad28e Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Sat, 3 Oct 2026 08:43:13 +0000 Subject: [PATCH 17/23] Bottom line: && and || paste a side only when that side runs. A skipped side is not pasted or parsed, so a missing reference, a $ call, a file: reference, or bad syntax on that side does not throw. A taken side still pastes and still throws. A single | still reads both sides. --- docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 5 + docs/block-conditionals.md | 2 + .../example/pickleball/SyntaxProofSteps.java | 12 + .../common/mappings/LazyBooleanSideTest.java | 119 ++++++ .../features/lazy-boolean-sides.feature | 63 +++ .../common/mappings/LazyBooleanSides.java | 393 ++++++++++++++++++ .../common/mappings/MappingProcessor.java | 109 +++++ .../dscode/common/mappings/ParsingMap.java | 75 ++++ .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 5 + .../guidance/docs/block-conditionals.md | 2 + .../META-INF/pickleball/guidance/index.txt | 2 + 13 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java create mode 100644 maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature create mode 100644 src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index efe03d0e4..6435e468b 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -22,7 +22,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation; `LazyBooleanSides`; `MappingProcessor.replaceLazyBooleanExpressions`. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only. A skipped IF, ELSE-IF, or ELSE branch is not resolved. Inside one expression, `&&` and `||` paste a side only when that side runs; a single `|` still pastes both sides | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `lazy-branch-resolution.feature`; `lazy-boolean-sides.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest`; `LazyBranchResolutionTest`; `LazyBooleanSideTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `RunRowCondition.java`; `CalledFeatureBackground.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection`, `RunIf`, `RunBackground` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; `run-row-gates.feature`; `SyntaxProofSteps.java`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index c5ac77b07..abe6d10b6 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -227,6 +227,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/mappings/GlobalMappings.java` - `src/main/java/tools/dscode/common/mappings/JsonataParentDemo.java` - `src/main/java/tools/dscode/common/mappings/JsonPathUtil.java` +- `src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java` - `src/main/java/tools/dscode/common/mappings/MapConfigurations.java` - `src/main/java/tools/dscode/common/mappings/MappingProcessor.java` - `src/main/java/tools/dscode/common/mappings/NodeMap.java` @@ -412,6 +413,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` - `src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java` +- `src/test/java/tools/dscode/common/treeparsing/preparsing/LazyBranchResolutionTest.java` - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballArtifactLocatorTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java` @@ -670,6 +672,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/tools/dscode/common/dataelements/DataElementPhaseThreeChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/dataelements/DataElementPhaseTwoChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/driver/ChromeHeadlessConfigChecks.java` +- `maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java` - `maven-consumer-project/src/test/java/tools/dscode/common/mappings/MappingDataRefactorChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/mappings/QuoteParserChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderChecks.java` @@ -711,6 +714,8 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/features/internal-framework-java-checks.feature` - `maven-consumer-project/src/test/resources/features/it-placeholder.feature` - `maven-consumer-project/src/test/resources/features/keyboard.feature` +- `maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature` +- `maven-consumer-project/src/test/resources/features/lazy-branch-resolution.feature` - `maven-consumer-project/src/test/resources/features/log-and-assert-steps.feature` - `maven-consumer-project/src/test/resources/features/mapping-and-resources.feature` - `maven-consumer-project/src/test/resources/features/mapping-value-type-preservation.feature` diff --git a/docs/block-conditionals.md b/docs/block-conditionals.md index 514513c16..58ffcfaa6 100644 --- a/docs/block-conditionals.md +++ b/docs/block-conditionals.md @@ -59,6 +59,8 @@ Parentheses can group expression parts: Expression parts are evaluated independently. They do not inherit a subject or comparison from a neighboring expression. +`&&` and `||` read each side only when that side runs. A skipped side is not pasted and is not parsed, so a missing reference, a `$` call, a `file:` reference, or bad syntax on that side does not run. A blank side is false. A single `|` is not a short-circuit and still reads both sides. A taken comparison still pastes saved text, so ` > 5` with A saved as `6` is the source `6 > 5`. + ## Inline branch chains Use `THEN:` when the result fits on one line: diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java index b6d0a9057..ad4373471 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java @@ -42,6 +42,18 @@ public void branchCountEquals(String name, String expected) { } } + @Given("^resolving \"([^\"]*)\" is recorded as \"([^\"]*)\"$") + public void resolvingIsRecorded(String expression, String key) { + try { + String value = ParsingMap.getRunningParsingMap().resolveWholeText("<{ " + expression + " }>"); + getRunMap().put(key, value == null ? "" : value); + getRunMap().put(key + "Status", "ok"); + } catch (RuntimeException ex) { + getRunMap().put(key, ""); + getRunMap().put(key + "Status", "threw"); + } + } + @Given("^the recorded city is saved as \"([^\"]*)\"$") public void recordCity(String key, DataTable table) { getRunMap().put(key, cityCell(table)); diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java b/maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java new file mode 100644 index 000000000..f7433577c --- /dev/null +++ b/maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java @@ -0,0 +1,119 @@ +package tools.dscode.common.mappings; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LazyBooleanSideTest { + @Test + void trueLeftSideDoesNotResolveTheMissingRightSide() { + CountingMap map = mapOf("A", "true"); + assertEquals("true", map.resolveWholeText("<{ || }>")); + assertTrue(map.reads.contains("A")); + assertFalse(map.reads.contains("MISSING")); + } + + @Test + void missingLeftSideFallsThroughToATrueRightSide() { + CountingMap map = mapOf("B", "true"); + assertEquals("true", map.resolveWholeText("<{ || }>")); + assertTrue(map.reads.contains("B")); + } + + @Test + void blankLeftSideIsFalseInsteadOfASyntaxError() { + CountingMap map = mapOf("A", "", "B", "true"); + assertEquals("true", map.resolveWholeText("<{ || }>")); + } + + @Test + void skippedDollarCallDoesNotRunAndATakenOneStillThrows() { + CountingMap map = new CountingMap(); + assertEquals("true", map.resolveWholeText("<{ true || <$nope> }>")); + assertThrows(RuntimeException.class, () -> map.resolveWholeText("<{ <$nope> || true }>")); + } + + @Test + void takenComparisonPastesANumericString() { + CountingMap map = mapOf("A", "6"); + assertEquals("true", map.resolveWholeText("<{ > 5 || }>")); + assertFalse(map.reads.contains("MISSING")); + } + + @Test + void singlePipeStillResolvesBothSides() { + CountingMap map = mapOf("L", "1", "R", "1"); + assertEquals("1", map.resolveWholeText("<{ | }>")); + assertTrue(map.reads.contains("L")); + assertTrue(map.reads.contains("R")); + + CountingMap missing = mapOf("L", "1"); + assertThrows(RuntimeException.class, () -> missing.resolveWholeText("<{ | }>")); + assertTrue(missing.reads.contains("L")); + assertTrue(missing.reads.contains("MISSING")); + } + + @Test + void badSyntaxOnASkippedSideDoesNotThrow() { + CountingMap map = mapOf("A", "true"); + assertEquals("true", map.resolveWholeText("<{ || ((( }>")); + } + + @Test + void badSyntaxOnATakenSideStillThrows() { + CountingMap map = mapOf("A", "true"); + assertThrows(RuntimeException.class, () -> map.resolveWholeText("<{ ((( || }>")); + } + + @Test + void nestedSkippedSideIsNotResolved() { + CountingMap map = mapOf("A", "true", "B", "true"); + assertEquals("true", map.resolveWholeText("<{ || ( && ) }>")); + assertFalse(map.reads.contains("MISSING")); + assertFalse(map.reads.contains("B")); + assertEquals("false", map.resolveWholeText("<{ false && ( || ) }>")); + assertFalse(map.reads.contains("MISSING")); + } + + @Test + void operatorsInsideQuotesAreNotSplits() { + CountingMap map = mapOf("A", "true"); + assertEquals("true", map.resolveWholeText("<{ \"a || b\" == \"a || b\" || }>")); + assertFalse(map.reads.contains("MISSING")); + } + + @Test + void falseAndSkipsTheMissingSide() { + CountingMap map = mapOf("A", "false"); + assertEquals("false", map.resolveWholeText("<{ && }>")); + assertFalse(map.reads.contains("MISSING")); + } + + private static CountingMap mapOf(String... pairs) { + CountingMap map = new CountingMap(); + for (int index = 0; index < pairs.length; index += 2) { + map.put(pairs[index], pairs[index + 1]); + } + return map; + } + + private static final class CountingMap extends ParsingMap { + private final List reads = new ArrayList<>(); + + private CountingMap() { + super(new NodeMap(MapConfigurations.MapType.RUN_MAP)); + } + + @Override + public Object get(String key) { + reads.add(key); + return super.get(key); + } + } +} diff --git a/maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature b/maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature new file mode 100644 index 000000000..5cc0c8eb8 --- /dev/null +++ b/maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature @@ -0,0 +1,63 @@ +@all @lazy-boolean-sides +Feature: Boolean sides resolve only when that side runs + + Scenario: A true left side does not resolve the missing right side + * , save "true" as "A" + * IF: || THEN: , save "yes" as "orMissing" + * , ensure "" equals "yes" + + Scenario: A missing left side falls through when the right side is true + * , save "true" as "B" + * IF: || THEN: , save "yes" as "orMissingRight" + * , ensure "" equals "yes" + + Scenario: A blank left side is false and does not break the expression + * , save "" as "A" + * , save "true" as "B" + * IF: || THEN: , save "yes" as "blankOr" + * , ensure "" equals "yes" + + Scenario: A dollar call on the skipped side does not run + * reset branch counts + * , save "true" as "A" + * IF: || <$count branch skipped-or> THEN: , save "yes" as "dollarOr" + * , ensure "" equals "yes" + * branch count "skipped-or" equals "0" + + Scenario: A taken comparison pastes a numeric string + * , save "6" as "A" + * IF: > 5 || THEN: , save "yes" as "numericOr" + * , ensure "" equals "yes" + + Scenario: A single pipe still resolves both sides + * reset branch counts + * IF: (<$count branch pipe-left> | <$count branch pipe-right>) == 1 THEN: , save "yes" as "pipeBoth" + * , ensure "" equals "yes" + * branch count "pipe-left" equals "1" + * branch count "pipe-right" equals "1" + + Scenario: Bad syntax on a skipped or side does not throw + * , save "true" as "A" + * resolving " || ((((" is recorded as "skippedSyntax" + * , ensure "" equals "ok" + * , ensure "" equals "true" + + Scenario: Bad syntax on a taken side still throws + * , save "true" as "A" + * resolving "((( || " is recorded as "takenSyntax" + * , ensure "" equals "threw" + + Scenario: A nested skipped side is not resolved + * reset branch counts + * , save "true" as "A" + * IF: || ( && <$count branch nested-and>) THEN: , save "yes" as "nestedOr" + * , ensure "" equals "yes" + * branch count "nested-and" equals "0" + * IF: false && ( || <$count branch nested-or>) THEN: , save "no" as "nestedAnd" ELSE: , save "yes" as "nestedAnd" + * , ensure "" equals "yes" + * branch count "nested-or" equals "0" + + Scenario: Operators inside quotes are not boolean splits + * , save "true" as "A" + * IF: "a || b" == "a || b" || THEN: , save "yes" as "quotedOr" + * , ensure "" equals "yes" diff --git a/src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java b/src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java new file mode 100644 index 000000000..955556b9a --- /dev/null +++ b/src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java @@ -0,0 +1,393 @@ +package tools.dscode.common.mappings; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static tools.dscode.common.evaluations.AviatorUtil.eval; +import static tools.dscode.common.evaluations.AviatorUtil.isTruthy; + +/** + * Splits {@code &&} and {@code ||} into sides before Aviator runs. + * A side is pasted only when that operator actually reads it. + * A single {@code |} is not a split. + */ +final class LazyBooleanSides { + private static final String TOKEN_PREFIX = "pkbLazy"; + + private LazyBooleanSides() { + } + + static boolean containsOperator(String text) { + return findOperator(text, 0, text.length(), false) >= 0; + } + + static boolean evaluate(MappingProcessor processor, String expression) { + String source = expression == null ? "" : expression; + Sides sides = new Sides(); + String rewritten = rewrite(source, sides); + if (sides.texts.isEmpty()) { + return pasteAndBoolean(processor, source); + } + Object value = eval(rewritten, sides.environment(processor)); + return isTruthy(value); + } + + private static String rewrite(String expression, Sides sides) { + String trimmed = expression == null ? "" : expression.trim(); + if (trimmed.isEmpty()) { + return sides.add(""); + } + String parenInner = unwrap(trimmed, "(", ")"); + if (parenInner != null) { + return rewrite(parenInner, sides); + } + List orSides = splitBy(trimmed, "||"); + if (orSides.size() > 1) { + return join(orSides, "||", sides); + } + List andSides = splitBy(trimmed, "&&"); + if (andSides.size() > 1) { + return join(andSides, "&&", sides); + } + String expanded = expandGroups(trimmed, sides); + if (!expanded.equals(trimmed)) { + return expanded; + } + return sides.add(trimmed); + } + + private static String join(List parts, String operator, Sides sides) { + StringBuilder built = new StringBuilder(); + for (int index = 0; index < parts.size(); index++) { + if (index > 0) { + built.append(' ').append(operator).append(' '); + } + built.append(sides.add(parts.get(index))); + } + return built.toString(); + } + + private static List splitBy(String text, String operator) { + List parts = new ArrayList<>(); + int sideStart = 0; + int paren = 0; + char quote = 0; + boolean escaped = false; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + if (paren == 0 && startsWith(text, index, "<{")) { + int close = matchingClose(text, index, "<{", "}>"); + if (close >= 0) { + index = close - 1; + continue; + } + } + if (paren == 0 && startsWith(text, index, "~[~{")) { + int close = matchingClose(text, index, "~[~{", "}~]~"); + if (close >= 0) { + index = close - 1; + continue; + } + } + if (current == '(') { + paren++; + continue; + } + if (current == ')' && paren > 0) { + paren--; + continue; + } + if (paren == 0 && text.startsWith(operator, index)) { + parts.add(text.substring(sideStart, index)); + index += operator.length() - 1; + sideStart = index + 1; + } + } + if (!parts.isEmpty()) { + parts.add(text.substring(sideStart)); + } + return parts; + } + + private static String expandGroups(String text, Sides sides) { + StringBuilder built = new StringBuilder(); + boolean changed = false; + char quote = 0; + boolean escaped = false; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + built.append(current); + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + built.append(current); + continue; + } + if (startsWith(text, index, "<{")) { + int close = matchingClose(text, index, "<{", "}>"); + if (close >= 0) { + String span = text.substring(index, close); + String body = text.substring(index + 2, close - 2); + if (containsOperator(body)) { + built.append(sides.add(span)); + changed = true; + } else { + built.append(span); + } + index = close - 1; + continue; + } + } + if (startsWith(text, index, "~[~{")) { + int close = matchingClose(text, index, "~[~{", "}~]~"); + if (close >= 0) { + String span = text.substring(index, close); + String body = text.substring(index + 4, close - 4); + if (containsOperator(body)) { + built.append(sides.add(span)); + changed = true; + } else { + built.append(span); + } + index = close - 1; + continue; + } + } + if (current == '(') { + int close = matchingClose(text, index, "(", ")"); + if (close >= 0) { + String span = text.substring(index, close); + String inner = text.substring(index + 1, close - 1); + if (containsOperator(inner)) { + built.append(sides.add(span)); + changed = true; + } else { + built.append(span); + } + index = close - 1; + continue; + } + } + built.append(current); + } + return changed ? built.toString() : text; + } + + private static boolean pasteAndBoolean(MappingProcessor processor, String side) { + String pasted = processor.resolveWholeText(side); + if (pasted == null || pasted.isBlank() || isWholeUnresolvedReference(pasted)) { + return false; + } + if (containsOperator(pasted) && !pasted.trim().equals(side.trim())) { + return evaluate(processor, pasted); + } + return isTruthy(eval(pasted, processor)); + } + + private static boolean isWholeUnresolvedReference(String text) { + String trimmed = text.trim(); + if (trimmed.length() < 3) { + return false; + } + if (trimmed.charAt(0) == '<' && trimmed.charAt(trimmed.length() - 1) == '>' + && trimmed.indexOf('<', 1) < 0 && trimmed.indexOf('>') == trimmed.length() - 1) { + return true; + } + return trimmed.startsWith("~[~") && trimmed.endsWith("~]~") + && trimmed.indexOf("~[~", 3) < 0 + && trimmed.lastIndexOf("~]~") == trimmed.length() - 3; + } + + private static int findOperator(String text, int start, int end, boolean topLevelOnly) { + int paren = 0; + char quote = 0; + boolean escaped = false; + for (int index = start; index < end; index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + if (topLevelOnly && current == '(') { + paren++; + continue; + } + if (topLevelOnly && current == ')' && paren > 0) { + paren--; + continue; + } + if (!topLevelOnly || paren == 0) { + if (operatorAt(text, index) != null && index < end) { + return index; + } + } + } + return -1; + } + + private static String operatorAt(String text, int index) { + if (index + 1 >= text.length()) { + return null; + } + char current = text.charAt(index); + char next = text.charAt(index + 1); + if ((current == '|' && next == '|') || (current == '&' && next == '&')) { + return text.substring(index, index + 2); + } + return null; + } + + private static String unwrap(String text, String open, String close) { + if (!text.startsWith(open) || !text.endsWith(close)) { + return null; + } + int end = matchingClose(text, 0, open, close); + if (end != text.length()) { + return null; + } + return text.substring(open.length(), text.length() - close.length()); + } + + static int matchingClose(String text, int openIndex, String open, String close) { + if (!startsWith(text, openIndex, open)) { + return -1; + } + int depth = 0; + char quote = 0; + boolean escaped = false; + for (int index = openIndex; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + if (startsWith(text, index, open)) { + depth++; + index += open.length() - 1; + continue; + } + if (startsWith(text, index, close)) { + depth--; + if (depth == 0) { + return index + close.length(); + } + index += close.length() - 1; + } + } + return -1; + } + + private static boolean startsWith(String text, int index, String token) { + return text.startsWith(token, index); + } + + private static final class Sides { + private final List texts = new ArrayList<>(); + + private String add(String side) { + String token = TOKEN_PREFIX + texts.size(); + texts.add(side == null ? "" : side.trim()); + return token; + } + + private HashMap environment(MappingProcessor processor) { + return new HashMap<>() { + private final Set reading = new HashSet<>(); + private final HashMap resolved = new HashMap<>(); + + @Override + public Object get(Object key) { + if (!(key instanceof String name) || !name.startsWith(TOKEN_PREFIX)) { + return super.get(key); + } + int index; + try { + index = Integer.parseInt(name.substring(TOKEN_PREFIX.length())); + } catch (NumberFormatException ex) { + return super.get(key); + } + if (index < 0 || index >= texts.size()) { + return super.get(key); + } + if (resolved.containsKey(name)) { + return resolved.get(name); + } + if (!reading.add(name)) { + throw new IllegalStateException("Cyclic boolean side " + name); + } + try { + boolean value = read(processor, texts.get(index)); + resolved.put(name, value); + return value; + } finally { + reading.remove(name); + } + } + }; + } + + private static boolean read(MappingProcessor processor, String raw) { + String side = raw == null ? "" : raw.trim(); + if (side.isEmpty()) { + return false; + } + String expression = unwrap(side, "<{", "}>"); + if (expression == null) { + expression = unwrap(side, "~[~{", "}~]~"); + } + if (expression != null && containsOperator(expression)) { + return evaluate(processor, expression); + } + if (containsOperator(side)) { + return evaluate(processor, side); + } + return pasteAndBoolean(processor, side); + } + } +} diff --git a/src/main/java/tools/dscode/common/mappings/MappingProcessor.java b/src/main/java/tools/dscode/common/mappings/MappingProcessor.java index ce37d00b0..a2e0e1a1c 100644 --- a/src/main/java/tools/dscode/common/mappings/MappingProcessor.java +++ b/src/main/java/tools/dscode/common/mappings/MappingProcessor.java @@ -531,6 +531,10 @@ private Object resolveAll( String originalInput; do { input = normalizeBookends(input, bookends); + if (resolveEvaluations) { + input = replaceLazyBooleanExpressions(input, parsedObj, bookends); + input = replaceRawTildeLazyExpressions(input, parsedObj, bookends); + } originalInput = input; String previousInput; do { @@ -654,6 +658,111 @@ private Object resolveByMap( } } + private String replaceLazyBooleanExpressions( + String input, + QuoteParser parsedObj, + Bookends bookends + ) { + StringBuilder output = new StringBuilder(); + int cursor = 0; + while (cursor < input.length()) { + int open = input.indexOf(INTERNAL_EXPRESSION_OPEN, cursor); + if (open < 0) { + output.append(input.substring(cursor)); + break; + } + int endExclusive = matchInternalExpression(input, open); + if (endExclusive < 0) { + output.append(input.substring(cursor)); + break; + } + String internalBody = input.substring( + open + INTERNAL_EXPRESSION_OPEN.length(), + endExclusive - INTERNAL_EXPRESSION_CLOSE.length()); + String restored = restoreExpressionBody(internalBody, parsedObj, bookends); + if (!LazyBooleanSides.containsOperator(restored)) { + output.append(input, cursor, endExclusive); + cursor = endExclusive; + continue; + } + output.append(input, cursor, open); + output.append(LazyBooleanSides.evaluate(this, restored)); + cursor = endExclusive; + } + return output.toString(); + } + + private String replaceRawTildeLazyExpressions( + String input, + QuoteParser parsedObj, + Bookends bookends + ) { + String open = "~[~{"; + String close = "}~]~"; + StringBuilder output = new StringBuilder(); + int cursor = 0; + while (cursor < input.length()) { + int start = input.indexOf(open, cursor); + if (start < 0) { + output.append(input.substring(cursor)); + break; + } + int end = LazyBooleanSides.matchingClose(input, start, open, close); + if (end < 0) { + output.append(input.substring(cursor)); + break; + } + String restored = restoreExpressionBody( + input.substring(start, end), parsedObj, bookends); + String body = restored.length() >= open.length() + close.length() + ? restored.substring(open.length(), restored.length() - close.length()) + : restored; + if (!LazyBooleanSides.containsOperator(body)) { + output.append(input, cursor, end); + cursor = end; + continue; + } + output.append(input, cursor, start); + output.append(LazyBooleanSides.evaluate(this, body)); + cursor = end; + } + return output.toString(); + } + + private static int matchInternalExpression(String input, int openIndex) { + int depth = 0; + for (int index = openIndex; index < input.length(); ) { + if (input.startsWith(INTERNAL_EXPRESSION_OPEN, index)) { + depth++; + index += INTERNAL_EXPRESSION_OPEN.length(); + continue; + } + if (input.startsWith(INTERNAL_EXPRESSION_CLOSE, index)) { + depth--; + index += INTERNAL_EXPRESSION_CLOSE.length(); + if (depth == 0) { + return index; + } + continue; + } + index++; + } + return -1; + } + + private String restoreExpressionBody( + String body, + QuoteParser parsedObj, + Bookends bookends + ) { + String restored = body + .replace(INTERNAL_MAP_OPEN, bookends.open()) + .replace(INTERNAL_MAP_CLOSE, bookends.close()) + .replace(INTERNAL_EXPRESSION_OPEN, bookends.expressionOpen()) + .replace(INTERNAL_EXPRESSION_CLOSE, bookends.expressionClose()); + return parsedObj.restoreAndStripBookEnds(decodeBackToText(restored)); + } + private String resolveExpression(String input, QuoteParser parsedObj, Bookends bookends) { Matcher matcher = EXPRESSION.matcher(input); StringBuffer output = new StringBuffer(); diff --git a/src/main/java/tools/dscode/common/mappings/ParsingMap.java b/src/main/java/tools/dscode/common/mappings/ParsingMap.java index bb64ddbe8..5acfc2c0d 100644 --- a/src/main/java/tools/dscode/common/mappings/ParsingMap.java +++ b/src/main/java/tools/dscode/common/mappings/ParsingMap.java @@ -907,6 +907,21 @@ private static Object resolveString( if (reference == null) { break; } + if (isLazyBooleanExpression(reference.body())) { + Object legacy = preserveWholeObject + ? owner.legacyResolveWholeValue( + reference.fullText(current), + resolveEvaluations) + : owner.legacyResolveWholeText( + reference.fullText(current), + resolveEvaluations); + current = replace( + current, + reference.start(), + reference.end() + 1, + legacy == null ? "" : String.valueOf(legacy)); + continue; + } Resolution resolution = resolveReference( owner, @@ -1265,12 +1280,72 @@ private static boolean isQuote(char value) { return value == '\'' || value == '"' || value == '`'; } + private static boolean isLazyBooleanExpression(String body) { + if (body == null) { + return false; + } + String trimmed = body.trim(); + return trimmed.startsWith("{") + && trimmed.endsWith("}") + && LazyBooleanSides.containsOperator(trimmed); + } + + private static Reference lazyBooleanReference( + String input, + int index, + String open, + String close + ) { + if (!input.startsWith(open, index)) { + return null; + } + int end = LazyBooleanSides.matchingClose(input, index, open, close); + if (end < 0) { + return null; + } + String body = input.substring( + index + open.length() - 1, + end - close.length() + 1); + if (!LazyBooleanSides.containsOperator(body)) { + return null; + } + return new Reference(index, end - 1, body); + } + private static Reference findInnermostReference(String input) { if (!looksLikeXml(input)) { List opens = new ArrayList<>(); + char quote = 0; + boolean escaped = false; for (int index = 0; index < input.length(); index++) { char current = input.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + } else if (current == '\'' || current == '"' || current == '`') { + quote = current; + } + boolean quoted = quote != 0 && current != quote; + if (!quoted && input.startsWith("~[~{", index)) { + Reference lazyTilde = lazyBooleanReference( + input, index, "~[~{", "}~]~"); + if (lazyTilde != null) { + return lazyTilde; + } + } if (current == '<' && isReferenceOpen(input, index)) { + if (!quoted) { + Reference lazyAngle = lazyBooleanReference( + input, index, "<{", "}>"); + if (lazyAngle != null) { + return lazyAngle; + } + } opens.add(index); continue; } 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 efe03d0e4..6435e468b 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 @@ -22,7 +22,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation; `LazyBooleanSides`; `MappingProcessor.replaceLazyBooleanExpressions`. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only. A skipped IF, ELSE-IF, or ELSE branch is not resolved. Inside one expression, `&&` and `||` paste a side only when that side runs; a single `|` still pastes both sides | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `lazy-branch-resolution.feature`; `lazy-boolean-sides.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest`; `LazyBranchResolutionTest`; `LazyBooleanSideTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `RunRowCondition.java`; `CalledFeatureBackground.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection`, `RunIf`, `RunBackground` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; `run-row-gates.feature`; `SyntaxProofSteps.java`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/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 c5ac77b07..abe6d10b6 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 @@ -227,6 +227,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/mappings/GlobalMappings.java` - `src/main/java/tools/dscode/common/mappings/JsonataParentDemo.java` - `src/main/java/tools/dscode/common/mappings/JsonPathUtil.java` +- `src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java` - `src/main/java/tools/dscode/common/mappings/MapConfigurations.java` - `src/main/java/tools/dscode/common/mappings/MappingProcessor.java` - `src/main/java/tools/dscode/common/mappings/NodeMap.java` @@ -412,6 +413,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` - `src/test/java/tools/dscode/common/treeparsing/preparsing/ConditionalBranchArgumentTest.java` +- `src/test/java/tools/dscode/common/treeparsing/preparsing/LazyBranchResolutionTest.java` - `src/test/java/tools/dscode/control/override/StepOverrideCompilerTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballArtifactLocatorTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java` @@ -670,6 +672,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/java/tools/dscode/common/dataelements/DataElementPhaseThreeChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/dataelements/DataElementPhaseTwoChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/driver/ChromeHeadlessConfigChecks.java` +- `maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java` - `maven-consumer-project/src/test/java/tools/dscode/common/mappings/MappingDataRefactorChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/mappings/QuoteParserChecks.java` - `maven-consumer-project/src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderChecks.java` @@ -711,6 +714,8 @@ This inventory helps coding agents discover relevant files. It does not replace - `maven-consumer-project/src/test/resources/features/internal-framework-java-checks.feature` - `maven-consumer-project/src/test/resources/features/it-placeholder.feature` - `maven-consumer-project/src/test/resources/features/keyboard.feature` +- `maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature` +- `maven-consumer-project/src/test/resources/features/lazy-branch-resolution.feature` - `maven-consumer-project/src/test/resources/features/log-and-assert-steps.feature` - `maven-consumer-project/src/test/resources/features/mapping-and-resources.feature` - `maven-consumer-project/src/test/resources/features/mapping-value-type-preservation.feature` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md b/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md index 514513c16..58ffcfaa6 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md @@ -59,6 +59,8 @@ Parentheses can group expression parts: Expression parts are evaluated independently. They do not inherit a subject or comparison from a neighboring expression. +`&&` and `||` read each side only when that side runs. A skipped side is not pasted and is not parsed, so a missing reference, a `$` call, a `file:` reference, or bad syntax on that side does not run. A blank side is false. A single `|` is not a short-circuit and still reads both sides. A taken comparison still pastes saved text, so ` > 5` with A saved as `6` is the source `6 > 5`. + ## Inline branch chains Use `THEN:` when the result fits on one line: diff --git a/src/main/resources/META-INF/pickleball/guidance/index.txt b/src/main/resources/META-INF/pickleball/guidance/index.txt index ecab9ce32..28a1bd70b 100644 --- a/src/main/resources/META-INF/pickleball/guidance/index.txt +++ b/src/main/resources/META-INF/pickleball/guidance/index.txt @@ -77,6 +77,8 @@ maven-consumer-project/src/test/resources/features/forms-dynamic-steps.feature maven-consumer-project/src/test/resources/features/internal-framework-java-checks.feature maven-consumer-project/src/test/resources/features/it-placeholder.feature maven-consumer-project/src/test/resources/features/keyboard.feature +maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature +maven-consumer-project/src/test/resources/features/lazy-branch-resolution.feature maven-consumer-project/src/test/resources/features/log-and-assert-steps.feature maven-consumer-project/src/test/resources/features/mapping-and-resources.feature maven-consumer-project/src/test/resources/features/mapping-value-type-preservation.feature From 5895c391af856dfd19e79db28eefd2c43521d33e Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Sun, 4 Oct 2026 09:39:34 +0000 Subject: [PATCH 18/23] Bottom line: ?: pastes an arm only when that arm runs. A skipped arm is not pasted or parsed. A trailing ? remains the boolean marker. Each expression logs one info line with taken parts filled in and skipped parts left as written. --- docs/agent/feature-map.md | 2 +- docs/block-conditionals.md | 4 + .../example/pickleball/SyntaxProofSteps.java | 95 ++ .../common/mappings/LazyBooleanSideTest.java | 96 ++ .../features/lazy-boolean-sides.feature | 50 ++ .../common/mappings/LazyBooleanSides.java | 836 +++++++++++++++++- .../common/mappings/MappingProcessor.java | 18 +- .../dscode/common/mappings/ParsingMap.java | 6 +- .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/block-conditionals.md | 4 + 10 files changed, 1051 insertions(+), 62 deletions(-) diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 6435e468b..21423d358 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -22,7 +22,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation; `LazyBooleanSides`; `MappingProcessor.replaceLazyBooleanExpressions`. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only. A skipped IF, ELSE-IF, or ELSE branch is not resolved. Inside one expression, `&&` and `||` paste a side only when that side runs; a single `|` still pastes both sides | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `lazy-branch-resolution.feature`; `lazy-boolean-sides.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest`; `LazyBranchResolutionTest`; `LazyBooleanSideTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation; `LazyBooleanSides`; `MappingProcessor.replaceLazyBooleanExpressions`. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only. A skipped IF, ELSE-IF, or ELSE branch is not resolved. Inside one expression, `&&`, `||`, and `?:` paste a component only when that component runs; a trailing `?` is still the boolean marker; a single `|` still pastes both sides. Each expression logs one info line with taken parts filled in and skipped parts left as written | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `lazy-branch-resolution.feature`; `lazy-boolean-sides.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest`; `LazyBranchResolutionTest`; `LazyBooleanSideTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `RunRowCondition.java`; `CalledFeatureBackground.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection`, `RunIf`, `RunBackground` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; `run-row-gates.feature`; `SyntaxProofSteps.java`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/docs/block-conditionals.md b/docs/block-conditionals.md index 58ffcfaa6..541939c21 100644 --- a/docs/block-conditionals.md +++ b/docs/block-conditionals.md @@ -61,6 +61,10 @@ Expression parts are evaluated independently. They do not inherit a subject or c `&&` and `||` read each side only when that side runs. A skipped side is not pasted and is not parsed, so a missing reference, a `$` call, a `file:` reference, or bad syntax on that side does not run. A blank side is false. A single `|` is not a short-circuit and still reads both sides. A taken comparison still pastes saved text, so ` > 5` with A saved as `6` is the source `6 > 5`. +`condition ? whenTrue : whenFalse` uses the same rule. The condition runs first. Only the arm that runs is pasted and evaluated. The other arm stays as written, so a missing reference, a `$` call, a `file:` reference, or bad syntax there does not throw. A taken arm still pastes and can throw. Nested `&&`, `||`, or `?:` inside a skipped arm or side is not pasted. A `?` at the very end of an expression is still the boolean marker (evaluate as boolean, then drop the `?`), not this operator. `!`, comparisons, math, a single `|`, and function arguments still run. + +Each expression reference logs one info line after it finishes. A part that ran is shown with its map references filled in. A part that never ran is shown as written. Then the line shows the result, for example `<{ || }> -> yes || -> true`. A plain expression with nothing to skip logs the original text, the filled-in text, and the result. If evaluation throws, an info line starting with `evaluation failed` names the original expression, shows that same picture, and includes the error. The error still propagates, and it still names the text that failed. + ## Inline branch chains Use `THEN:` when the result fits on one line: diff --git a/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java index ad4373471..1dccd66f6 100644 --- a/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java +++ b/maven-consumer-project/src/test/java/com/example/pickleball/SyntaxProofSteps.java @@ -6,6 +6,8 @@ import io.cucumber.docstring.DocString; import io.cucumber.java.en.Given; import tools.dscode.common.mappings.ParsingMap; +import tools.dscode.common.reporting.logging.Entry; +import tools.dscode.common.reporting.logging.LogForwarder; import tools.dscode.launcher.WorkbenchAgentCommands; import java.io.ByteArrayOutputStream; @@ -13,7 +15,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; @@ -44,6 +48,42 @@ public void branchCountEquals(String name, String expected) { @Given("^resolving \"([^\"]*)\" is recorded as \"([^\"]*)\"$") public void resolvingIsRecorded(String expression, String key) { + recordResolution(expression, key, null); + } + + @Given("^resolving \"([^\"]*)\" is recorded as \"([^\"]*)\" and the info contains \"([^\"]*)\"$") + public void resolvingIsRecordedWithInfo(String expression, String key, String expectedLog) { + recordResolution(expression, key, expectedLog); + } + + @Given("^resolving \"([^\"]*)\" fails as \"([^\"]*)\" and the info contains \"([^\"]*)\"$") + public void resolvingFailsWithInfo(String expression, String key, String expectedLog) { + Set before = infoIds(); + RuntimeException thrown; + try { + ParsingMap.getRunningParsingMap().resolveWholeText("<{ " + expression + " }>"); + thrown = null; + } catch (RuntimeException ex) { + thrown = ex; + } + String log = joinedNewInfo(before); + getRunMap().put(key + "Status", thrown == null ? "ok" : "threw"); + if (thrown == null) { + throw new AssertionError("expected evaluation to fail for <{ " + expression + " }>"); + } + String message = thrown.getMessage() == null ? "" : thrown.getMessage(); + if (!log.contains("evaluation failed") + || !log.contains(expectedLog) + || log.contains("pkbLazy") + || message.contains("pkbLazy") + || !message.contains("(((")) { + throw new AssertionError("failure picture was [" + log + "] error [" + message + "]"); + } + getRunMap().put(key, "threw"); + } + + private void recordResolution(String expression, String key, String expectedLog) { + Set before = infoIds(); try { String value = ParsingMap.getRunningParsingMap().resolveWholeText("<{ " + expression + " }>"); getRunMap().put(key, value == null ? "" : value); @@ -52,6 +92,61 @@ public void resolvingIsRecorded(String expression, String key) { getRunMap().put(key, ""); getRunMap().put(key + "Status", "threw"); } + if (expectedLog == null) { + return; + } + String log = joinedNewInfo(before); + boolean matched = log.contains(expectedLog) && !log.contains("pkbLazy"); + getRunMap().put(key + "LogOk", matched ? "yes" : "no"); + if (!matched) { + throw new AssertionError("info log [" + log + "] did not contain [" + expectedLog + "]"); + } + } + + private static Set infoIds() { + Set ids = new HashSet<>(); + collectIds(LogForwarder.getDefaultEntry(), ids); + try { + collectIds(LogForwarder.closestEntryToStep(), ids); + } catch (RuntimeException ignored) { + // A unit-style call has no running step. + } + return ids; + } + + private static void collectIds(Entry entry, Set ids) { + if (entry == null || !ids.add(entry.id)) { + return; + } + for (Entry child : entry.children) { + collectIds(child, ids); + } + } + + private static String joinedNewInfo(Set before) { + StringBuilder joined = new StringBuilder(); + appendNew(LogForwarder.getDefaultEntry(), before, joined, new HashSet<>()); + try { + appendNew(LogForwarder.closestEntryToStep(), before, joined, new HashSet<>()); + } catch (RuntimeException ignored) { + // A unit-style call has no running step. + } + return joined.toString(); + } + + private static void appendNew(Entry entry, Set before, StringBuilder joined, Set seen) { + if (entry == null || !seen.add(entry.id)) { + return; + } + if (!before.contains(entry.id) && entry.text != null && entry.text.contains("->")) { + if (joined.length() > 0) { + joined.append('\n'); + } + joined.append(entry.text); + } + for (Entry child : entry.children) { + appendNew(child, before, joined, seen); + } } @Given("^the recorded city is saved as \"([^\"]*)\"$") diff --git a/maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java b/maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java index f7433577c..e5030914c 100644 --- a/maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java +++ b/maven-consumer-project/src/test/java/tools/dscode/common/mappings/LazyBooleanSideTest.java @@ -2,6 +2,10 @@ import org.junit.jupiter.api.Test; +import tools.dscode.common.reporting.logging.Entry; +import tools.dscode.common.reporting.logging.LogForwarder; + +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; @@ -95,6 +99,98 @@ void falseAndSkipsTheMissingSide() { assertFalse(map.reads.contains("MISSING")); } + @Test + void skippedTernaryArmDoesNotResolveAMissingReference() { + CountingMap map = mapOf("Flag", "true", "Taken", "6"); + assertEquals("6", map.resolveWholeText("<{ ? : }>")); + assertTrue(map.reads.contains("Flag")); + assertTrue(map.reads.contains("Taken")); + assertFalse(map.reads.contains("MISSING")); + String log = latestLog("-> true ? 6 : -> 6"); + assertTrue(log.contains("<{ ? : }> -> true ? 6 : -> 6"), log); + assertFalse(log.contains("pkbLazy"), log); + } + + @Test + void skippedTernaryArmKeepsNestedOperatorsUnpasted() { + CountingMap map = mapOf("A", "true"); + assertEquals("1", map.resolveWholeText("<{ false ? ( && ) : 1 }>")); + assertFalse(map.reads.contains("MISSING")); + assertFalse(map.reads.contains("A")); + assertEquals("true", map.resolveWholeText("<{ false ? : ( || ) }>")); + assertTrue(map.reads.contains("A")); + assertFalse(map.reads.contains("MISSING")); + assertFalse(map.reads.contains("NOPE")); + } + + @Test + void takenTernaryArmPastesAndCanThrow() { + CountingMap map = mapOf("Taken", "6"); + assertEquals("1", map.resolveWholeText("<{ false ? ((( : 1 }>")); + assertFalse(map.reads.contains("MISSING")); + RuntimeException thrown = assertThrows( + RuntimeException.class, + () -> map.resolveWholeText("<{ true ? ((( : }>")); + assertTrue(thrown.getMessage().contains("((("), thrown.getMessage()); + assertFalse(thrown.getMessage().contains("pkbLazy"), thrown.getMessage()); + assertFalse(map.reads.contains("MISSING")); + String log = latestLog("true ? ((( : "); + assertTrue(log.contains("evaluation failed"), log); + assertTrue(log.contains("true ? ((( : "), log); + assertFalse(log.contains("pkbLazy"), log); + assertEquals("6", map.resolveWholeText("<{ false ? : }>")); + assertTrue(map.reads.contains("Taken")); + } + + @Test + void trailingQuestionMarkIsStillTheBooleanMarker() { + CountingMap map = mapOf("A", "true"); + assertEquals("true", map.resolveWholeText("<{ ? }>")); + assertEquals("false", map.resolveWholeText("<{ 1 == 0? }>")); + assertEquals("true", map.resolveWholeText("<{ true ? true : ? }>")); + assertFalse(map.reads.contains("MISSING")); + assertThrows(RuntimeException.class, () -> map.resolveWholeText("<{ true ? : false? }>")); + } + + @Test + void plainExpressionLogsFilledText() { + CountingMap map = mapOf("A", "6", "B", "1"); + assertEquals("7", map.resolveWholeText("<{ + }>")); + assertTrue(map.reads.contains("A")); + assertTrue(map.reads.contains("B")); + String log = latestLog("-> 6 + 1 -> 7"); + assertTrue(log.contains("<{ + }> -> 6 + 1 -> 7"), log); + assertFalse(log.contains("pkbLazy"), log); + } + + @Test + void notAndComparisonAndFunctionArgumentStillRun() { + CountingMap map = mapOf("A", "true", "B", "9", "C", "4"); + assertEquals("false", map.resolveWholeText("<{ ! && }>")); + assertFalse(map.reads.contains("MISSING")); + assertEquals("true", map.resolveWholeText("<{ > || }>")); + assertTrue(map.reads.contains("B")); + assertTrue(map.reads.contains("C")); + assertFalse(map.reads.contains("MISSING")); + assertEquals("true", map.resolveWholeText("<{ bool(true ? 1 : ) }>")); + assertFalse(map.reads.contains("MISSING")); + assertEquals("true", map.resolveWholeText("<{ !(true ? false : ) }>")); + } + + private static String latestLog(String needle) { + String found = null; + ArrayDeque pending = new ArrayDeque<>(); + pending.add(LogForwarder.getDefaultEntry()); + while (!pending.isEmpty()) { + Entry entry = pending.removeFirst(); + if (entry.text != null && entry.text.contains(needle)) { + found = entry.text; + } + pending.addAll(entry.children); + } + return found == null ? "" : found; + } + private static CountingMap mapOf(String... pairs) { CountingMap map = new CountingMap(); for (int index = 0; index < pairs.length; index += 2) { diff --git a/maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature b/maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature index 5cc0c8eb8..37809a881 100644 --- a/maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature +++ b/maven-consumer-project/src/test/resources/features/lazy-boolean-sides.feature @@ -61,3 +61,53 @@ Feature: Boolean sides resolve only when that side runs * , save "true" as "A" * IF: "a || b" == "a || b" || THEN: , save "yes" as "quotedOr" * , ensure "" equals "yes" + + Scenario: A skipped ternary arm with a missing reference does not throw + * , save "true" as "Flag" + * , save "6" as "Taken" + * resolving " ? : " is recorded as "ternarySkip" and the info contains "-> true ? 6 : -> 6" + * , ensure "" equals "ok" + * , ensure "" equals "6" + * , ensure "" equals "yes" + + Scenario: A skipped ternary arm keeps nested operators and a dollar call unpasted + * reset branch counts + * resolving "false ? ( && ) : 1" is recorded as "ternaryNestedSkip" and the info contains "-> false ? ( && ) : 1 -> 1" + * , ensure "" equals "ok" + * , ensure "" equals "1" + * , ensure "" equals "yes" + * IF: <{ false ? <$count branch ternary-skip> : true }> THEN: , save "yes" as "ternaryDollar" + * , ensure "" equals "yes" + * branch count "ternary-skip" equals "0" + + Scenario: A taken ternary arm pastes and still throws + * resolving "true ? ((( : " fails as "ternaryTaken" and the info contains "-> true ? ((( : ->" + * , ensure "" equals "threw" + + Scenario: A trailing question mark still means boolean + * , save "true" as "A" + * resolving "?" is recorded as "trailingTrue" and the info contains "-> true -> true" + * , ensure "" equals "ok" + * , ensure "" equals "true" + * , ensure "" equals "yes" + * resolving "1 == 0?" is recorded as "trailingFalse" and the info contains "-> 1 == 0 -> false" + * , ensure "" equals "false" + + Scenario: And and or still paste only the side that runs + * , save "true" as "A" + * resolving " || " is recorded as "orStill" and the info contains "-> true || -> true" + * , ensure "" equals "ok" + * , ensure "" equals "true" + * , ensure "" equals "yes" + * , save "false" as "A" + * resolving " && " is recorded as "andStill" and the info contains "-> false && -> false" + * , ensure "" equals "false" + * , ensure "" equals "yes" + + Scenario: A plain expression logs the filled text and the result + * , save "6" as "A" + * resolving " + 1" is recorded as "plainSum" and the info contains "-> 6 + 1 -> 7" + * , ensure "" equals "ok" + * , ensure "" equals "7" + * , ensure "" equals "yes" + diff --git a/src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java b/src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java index 955556b9a..475d23b54 100644 --- a/src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java +++ b/src/main/java/tools/dscode/common/mappings/LazyBooleanSides.java @@ -1,6 +1,8 @@ package tools.dscode.common.mappings; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -8,11 +10,12 @@ import static tools.dscode.common.evaluations.AviatorUtil.eval; import static tools.dscode.common.evaluations.AviatorUtil.isTruthy; +import static tools.dscode.common.reporting.logging.LogForwarder.logInfo; /** - * Splits {@code &&} and {@code ||} into sides before Aviator runs. - * A side is pasted only when that operator actually reads it. - * A single {@code |} is not a split. + * Splits {@code &&}, {@code ||}, and {@code ?:} before any map paste. + * A component is pasted only when that operator actually reads it. + * A single {@code |} is not a split. A trailing {@code ?} is the boolean marker. */ final class LazyBooleanSides { private static final String TOKEN_PREFIX = "pkbLazy"; @@ -24,15 +27,289 @@ static boolean containsOperator(String text) { return findOperator(text, 0, text.length(), false) >= 0; } - static boolean evaluate(MappingProcessor processor, String expression) { + /** + * Evaluates one expression reference and logs one info line. + * Taken components are filled in. Skipped components stay as written. + */ + static String evaluateReference( + MappingProcessor processor, + String expression, + String open, + String close + ) { String source = expression == null ? "" : expression; + EvalTrace trace = new EvalTrace(); + try { + Object value = evaluateValue(processor, source, trace); + String result = stringify(value); + logInfo(reference(open, source, close) + " -> " + picture(trace, source) + " -> " + result); + return result; + } catch (RuntimeException ex) { + RuntimeException named = unwrapTokenFailure(ex); + String message = named.getMessage() == null ? named.toString() : named.getMessage(); + logInfo("evaluation failed " + reference(open, source, close) + + " -> " + picture(trace, source) + + " -> " + message); + throw named; + } + } + + private static Object evaluateValue( + MappingProcessor processor, + String expression, + EvalTrace trace + ) { + String source = expression == null ? "" : expression; + String trimmed = source.trim(); + if (trace.picture == null) { + trace.picture = trimmed; + } + if (trimmed.isEmpty()) { + trace.picture = ""; + return evalPlain(processor, "", trace); + } + if (endsWithBooleanMarker(trimmed)) { + String inner = trimmed.substring(0, trimmed.length() - 1).trim(); + EvalTrace innerTrace = new EvalTrace(); + try { + Object value = evaluateValue(processor, inner, innerTrace); + trace.picture = innerTrace.picture == null ? inner : innerTrace.picture; + return asBooleanMarker(value, inner); + } catch (RuntimeException ex) { + trace.picture = innerTrace.picture == null ? inner : innerTrace.picture; + throw ex; + } + } + Ternary ternary = splitTernary(trimmed); + if (ternary != null) { + return evaluateTernary(processor, ternary, trace); + } + if (containsOperator(trimmed)) { + return evaluateBoolean(processor, trimmed, trace); + } + int[] group = innermostTernaryGroup(trimmed); + if (group != null) { + return evaluateGroupedTernary(processor, trimmed, group, trace); + } + return evalPlain(processor, trimmed, trace); + } + + private static Object evaluateTernary( + MappingProcessor processor, + Ternary ternary, + EvalTrace trace + ) { + Sides sides = new Sides(); + String rewritten = sides.add(ternary.condition, true) + + " ? " + sides.add(ternary.whenTrue, false) + + " : " + sides.add(ternary.whenFalse, false); + try { + Object value = evalRewritten(rewritten, sides.environment(processor)); + trace.picture = sides.picture(rewritten); + return value; + } catch (RuntimeException ex) { + trace.picture = sides.picture(rewritten); + throw unwrapTokenFailure(ex); + } + } + + private static boolean evaluateBoolean( + MappingProcessor processor, + String expression, + EvalTrace trace + ) { Sides sides = new Sides(); - String rewritten = rewrite(source, sides); - if (sides.texts.isEmpty()) { - return pasteAndBoolean(processor, source); + String rewritten = rewrite(expression, sides); + try { + Object value = evalRewritten(rewritten, sides.environment(processor)); + trace.picture = sides.picture(rewritten); + return isTruthy(value); + } catch (RuntimeException ex) { + trace.picture = sides.picture(rewritten); + throw unwrapTokenFailure(ex); + } + } + + private static Object evaluateGroupedTernary( + MappingProcessor processor, + String text, + int[] group, + EvalTrace trace + ) { + int open = group[0]; + int close = group[1]; + String interior = text.substring(open + 1, close - 1); + if (isFunctionCall(text, open)) { + List args = splitArgs(interior); + StringBuilder literals = new StringBuilder(); + StringBuilder pictures = new StringBuilder(); + RuntimeException failure = null; + for (int index = 0; index < args.size(); index++) { + if (index > 0) { + literals.append(", "); + pictures.append(", "); + } + String arg = args.get(index); + if (failure != null) { + pictures.append(arg.trim()); + continue; + } + EvalTrace argTrace = new EvalTrace(); + try { + Object value = evaluateValue(processor, arg, argTrace); + literals.append(toLiteral(value)); + pictures.append(argTrace.picture == null ? arg.trim() : argTrace.picture); + } catch (RuntimeException ex) { + pictures.append(argTrace.picture == null ? arg.trim() : argTrace.picture); + failure = ex; + } + } + String lazyPicture = text.substring(0, open + 1) + pictures + text.substring(close - 1); + trace.picture = lazyPicture; + if (failure != null) { + throw failure; + } + String evalText = text.substring(0, open + 1) + literals + text.substring(close - 1); + return evalReduced(processor, evalText, trace, lazyPicture); + } + EvalTrace inner = new EvalTrace(); + try { + Object value = evaluateValue(processor, interior, inner); + String lazyPicture = text.substring(0, open) + + (inner.picture == null ? interior.trim() : inner.picture) + + text.substring(close); + trace.picture = lazyPicture; + String evalText = text.substring(0, open) + toLiteral(value) + text.substring(close); + return evalReduced(processor, evalText, trace, lazyPicture); + } catch (RuntimeException ex) { + trace.picture = text.substring(0, open) + + (inner.picture == null ? interior.trim() : inner.picture) + + text.substring(close); + throw ex; + } + } + + private static Object evalReduced( + MappingProcessor processor, + String evalText, + EvalTrace trace, + String lazyPicture + ) { + try { + Object value; + if (containsOperator(evalText) || hasTernary(evalText) || endsWithBooleanMarker(evalText.trim())) { + EvalTrace nested = new EvalTrace(); + value = evaluateValue(processor, evalText, nested); + } else { + EvalTrace nested = new EvalTrace(); + value = evalPlain(processor, evalText, nested); + } + trace.picture = lazyPicture; + return value; + } catch (RuntimeException ex) { + trace.picture = lazyPicture; + throw ex; + } + } + + private static Object evalPlain( + MappingProcessor processor, + String text, + EvalTrace trace + ) { + String pasted; + try { + pasted = processor.resolveWholeText(text); + } catch (RuntimeException ex) { + if (trace.picture == null) { + trace.picture = text.trim(); + } + throw ex; + } + String shown = pasted == null ? "" : pasted.trim(); + trace.picture = shown; + if ((containsOperator(shown) || hasTernary(shown) || endsWithBooleanMarker(shown)) + && !shown.equals(text.trim())) { + EvalTrace inner = new EvalTrace(); + try { + Object value = evaluateValue(processor, pasted, inner); + trace.picture = inner.picture == null ? shown : inner.picture; + return value; + } catch (RuntimeException ex) { + trace.picture = inner.picture == null ? shown : inner.picture; + throw ex; + } + } + return eval(pasted == null ? "" : pasted, processor); + } + + private static Object evalRewritten(String rewritten, HashMap environment) { + try { + return eval(rewritten, environment); + } catch (RuntimeException ex) { + throw unwrapTokenFailure(ex); + } + } + + private static RuntimeException unwrapTokenFailure(Throwable failure) { + Throwable current = failure; + while (current != null + && current.getMessage() != null + && current.getMessage().contains(TOKEN_PREFIX) + && current.getCause() != null + && current.getCause() != current) { + current = current.getCause(); + } + if (current instanceof RuntimeException runtime) { + return runtime; } - Object value = eval(rewritten, sides.environment(processor)); - return isTruthy(value); + if (failure instanceof RuntimeException runtime) { + return runtime; + } + return new RuntimeException(failure); + } + + private static boolean asBooleanMarker(Object value, String source) { + if (value instanceof Boolean bool) { + return bool; + } + try { + return (boolean) value; + } catch (ClassCastException ex) { + throw new RuntimeException( + "Failed to evaluate expression to boolean: '" + source + "'", + ex); + } + } + + private static String stringify(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static String reference(String open, String source, String close) { + return (open == null ? "" : open) + source + (close == null ? "" : close); + } + + private static String picture(EvalTrace trace, String source) { + if (trace.picture == null) { + return source == null ? "" : source.trim(); + } + return trace.picture; + } + + private static String toLiteral(Object value) { + if (value == null) { + return "nil"; + } + if (value instanceof Boolean || value instanceof Number) { + return String.valueOf(value); + } + String text = String.valueOf(value); + return "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private static boolean endsWithBooleanMarker(String text) { + return text != null && !text.isEmpty() && text.charAt(text.length() - 1) == '?'; } private static String rewrite(String expression, Sides sides) { @@ -92,19 +369,10 @@ private static List splitBy(String text, String operator) { quote = current; continue; } - if (paren == 0 && startsWith(text, index, "<{")) { - int close = matchingClose(text, index, "<{", "}>"); - if (close >= 0) { - index = close - 1; - continue; - } - } - if (paren == 0 && startsWith(text, index, "~[~{")) { - int close = matchingClose(text, index, "~[~{", "}~]~"); - if (close >= 0) { - index = close - 1; - continue; - } + int skip = skipOpaque(text, index); + if (skip >= 0) { + index = skip - 1; + continue; } if (current == '(') { paren++; @@ -154,7 +422,7 @@ private static String expandGroups(String text, Sides sides) { if (close >= 0) { String span = text.substring(index, close); String body = text.substring(index + 2, close - 2); - if (containsOperator(body)) { + if (containsOperator(body) || hasTernary(body)) { built.append(sides.add(span)); changed = true; } else { @@ -169,7 +437,7 @@ private static String expandGroups(String text, Sides sides) { if (close >= 0) { String span = text.substring(index, close); String body = text.substring(index + 4, close - 4); - if (containsOperator(body)) { + if (containsOperator(body) || hasTernary(body)) { built.append(sides.add(span)); changed = true; } else { @@ -184,7 +452,7 @@ private static String expandGroups(String text, Sides sides) { if (close >= 0) { String span = text.substring(index, close); String inner = text.substring(index + 1, close - 1); - if (containsOperator(inner)) { + if (containsOperator(inner) || hasTernary(inner)) { built.append(sides.add(span)); changed = true; } else { @@ -199,13 +467,24 @@ private static String expandGroups(String text, Sides sides) { return changed ? built.toString() : text; } - private static boolean pasteAndBoolean(MappingProcessor processor, String side) { + private static boolean pasteAndBoolean(MappingProcessor processor, String side, Sides sides, int index) { String pasted = processor.resolveWholeText(side); + String shown = pasted == null ? "" : pasted.trim(); + sides.mark(index, shown); if (pasted == null || pasted.isBlank() || isWholeUnresolvedReference(pasted)) { return false; } - if (containsOperator(pasted) && !pasted.trim().equals(side.trim())) { - return evaluate(processor, pasted); + if ((containsOperator(pasted) || hasTernary(pasted) || endsWithBooleanMarker(pasted)) + && !pasted.trim().equals(side.trim())) { + EvalTrace inner = new EvalTrace(); + try { + Object value = evaluateValue(processor, pasted, inner); + sides.mark(index, inner.picture == null ? shown : inner.picture); + return isTruthy(value); + } catch (RuntimeException ex) { + sides.mark(index, inner.picture == null ? shown : inner.picture); + throw ex; + } } return isTruthy(eval(pasted, processor)); } @@ -327,19 +606,449 @@ private static boolean startsWith(String text, int index, String token) { return text.startsWith(token, index); } + private static int skipOpaque(String text, int index) { + if (startsWith(text, index, "<{")) { + int end = matchingClose(text, index, "<{", "}>"); + if (end >= 0) { + return end; + } + } + if (startsWith(text, index, "~[~{")) { + int end = matchingClose(text, index, "~[~{", "}~]~"); + if (end >= 0) { + return end; + } + } + if (startsWith(text, index, "~[~")) { + int end = matchingClose(text, index, "~[~", "~]~"); + if (end >= 0) { + return end; + } + } + if (isMapOpen(text, index)) { + int end = mapClose(text, index); + if (end >= 0) { + return end; + } + } + return -1; + } + + private static boolean isMapOpen(String text, int index) { + if (text.charAt(index) != '<' || startsWith(text, index, "<{")) { + return false; + } + if (index + 1 >= text.length()) { + return false; + } + char next = text.charAt(index + 1); + return !Character.isWhitespace(next) && next != '='; + } + + private static int mapClose(String text, int open) { + for (int index = open + 1; index < text.length(); index++) { + if (text.charAt(index) == '>' && !Character.isWhitespace(text.charAt(index - 1))) { + return index + 1; + } + } + return -1; + } + + private static boolean hasTernary(String text) { + if (text == null || text.isBlank()) { + return false; + } + if (splitTernary(text.trim()) != null) { + return true; + } + char quote = 0; + boolean escaped = false; + Deque opens = new ArrayDeque<>(); + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + int skip = skipOpaque(text, index); + if (skip >= 0) { + index = skip - 1; + continue; + } + if (current == '(') { + opens.push(index); + continue; + } + if (current == ')' && !opens.isEmpty()) { + int open = opens.pop(); + if (hasTernary(text.substring(open + 1, index))) { + return true; + } + } + } + return false; + } + + private static int[] innermostTernaryGroup(String text) { + int[] best = null; + char quote = 0; + boolean escaped = false; + Deque opens = new ArrayDeque<>(); + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + int skip = skipOpaque(text, index); + if (skip >= 0) { + index = skip - 1; + continue; + } + if (current == '(') { + opens.push(index); + continue; + } + if (current == ')' && !opens.isEmpty()) { + int open = opens.pop(); + String interior = text.substring(open + 1, index); + if (hasTernary(interior)) { + int span = index + 1 - open; + if (best == null || span < best[2]) { + best = new int[]{open, index + 1, span}; + } + } + } + } + return best == null ? null : new int[]{best[0], best[1]}; + } + + private static Ternary splitTernary(String text) { + int question = findTopLevelQuestion(text); + if (question < 0) { + return null; + } + int colon = findMatchingColon(text, question + 1, false); + if (colon < 0) { + colon = findMatchingColon(text, question + 1, true); + } + if (colon < 0) { + return null; + } + String condition = text.substring(0, question); + String whenTrue = text.substring(question + 1, colon); + String whenFalse = text.substring(colon + 1); + if (hasDepth0Comma(condition) || hasDepth0Comma(whenTrue) || hasDepth0Comma(whenFalse)) { + return null; + } + return new Ternary(condition, whenTrue, whenFalse); + } + + private static int findTopLevelQuestion(String text) { + int paren = 0; + char quote = 0; + boolean escaped = false; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + int skip = skipOpaque(text, index); + if (skip >= 0) { + index = skip - 1; + continue; + } + if (current == '(') { + paren++; + continue; + } + if (current == ')' && paren > 0) { + paren--; + continue; + } + if (paren == 0 && current == '?' && !followedByColon(text, index) && !isTrailingMarker(text, index)) { + return index; + } + } + return -1; + } + + private static int findMatchingColon(String text, int start, boolean ignoreParens) { + int paren = 0; + int nested = 0; + char quote = 0; + boolean escaped = false; + for (int index = start; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + int skip = skipOpaque(text, index); + if (skip >= 0) { + index = skip - 1; + continue; + } + if (!ignoreParens && current == '(') { + paren++; + continue; + } + if (!ignoreParens && current == ')' && paren > 0) { + paren--; + continue; + } + if (!ignoreParens && paren != 0) { + continue; + } + if (current == '?' && !followedByColon(text, index)) { + nested++; + continue; + } + if (current == ':' && (index == 0 || text.charAt(index - 1) != '?')) { + if (nested > 0) { + nested--; + } else { + return index; + } + } + } + return -1; + } + + private static boolean hasDepth0Comma(String text) { + int paren = 0; + int nested = 0; + char quote = 0; + boolean escaped = false; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + int skip = skipOpaque(text, index); + if (skip >= 0) { + index = skip - 1; + continue; + } + if (current == '(') { + paren++; + continue; + } + if (current == ')' && paren > 0) { + paren--; + continue; + } + if (paren != 0) { + continue; + } + if (current == '?' && !followedByColon(text, index)) { + nested++; + continue; + } + if (current == ':' && nested > 0 && (index == 0 || text.charAt(index - 1) != '?')) { + nested--; + continue; + } + if (nested == 0 && current == ',') { + return true; + } + } + return false; + } + + private static boolean followedByColon(String text, int question) { + for (int index = question + 1; index < text.length(); index++) { + char current = text.charAt(index); + if (Character.isWhitespace(current)) { + continue; + } + return current == ':'; + } + return false; + } + + private static boolean isTrailingMarker(String text, int question) { + for (int index = question + 1; index < text.length(); index++) { + if (!Character.isWhitespace(text.charAt(index))) { + return false; + } + } + return true; + } + + private static boolean isFunctionCall(String text, int openParen) { + int index = openParen - 1; + while (index >= 0 && Character.isWhitespace(text.charAt(index))) { + index--; + } + if (index < 0) { + return false; + } + char current = text.charAt(index); + return Character.isLetterOrDigit(current) || current == '_' || current == '.'; + } + + private static List splitArgs(String text) { + List args = new ArrayList<>(); + int start = 0; + int paren = 0; + int nested = 0; + char quote = 0; + boolean escaped = false; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quote != 0) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') { + quote = current; + continue; + } + int skip = skipOpaque(text, index); + if (skip >= 0) { + index = skip - 1; + continue; + } + if (current == '(') { + paren++; + continue; + } + if (current == ')' && paren > 0) { + paren--; + continue; + } + if (paren != 0) { + continue; + } + if (current == '?' && !followedByColon(text, index)) { + nested++; + continue; + } + if (current == ':' && nested > 0 && (index == 0 || text.charAt(index - 1) != '?')) { + nested--; + continue; + } + if (nested == 0 && current == ',') { + args.add(text.substring(start, index)); + start = index + 1; + } + } + args.add(text.substring(start)); + return args; + } + + private static final class EvalTrace { + private String picture; + } + + private record Ternary(String condition, String whenTrue, String whenFalse) { + } + private static final class Sides { private final List texts = new ArrayList<>(); + private final List booleanContext = new ArrayList<>(); + private final List ran = new ArrayList<>(); + private final List displays = new ArrayList<>(); private String add(String side) { - String token = TOKEN_PREFIX + texts.size(); + return add(side, true); + } + + private String add(String side, boolean asBoolean) { texts.add(side == null ? "" : side.trim()); - return token; + booleanContext.add(asBoolean); + ran.add(false); + displays.add(null); + return TOKEN_PREFIX + (texts.size() - 1); + } + + private void mark(int index, String shown) { + ran.set(index, true); + displays.set(index, shown == null ? "" : shown); + } + + private String picture(String rewritten) { + String shown = rewritten; + for (int index = texts.size() - 1; index >= 0; index--) { + String display = ran.get(index) ? displays.get(index) : texts.get(index); + if (display == null) { + display = ""; + } + String original = texts.get(index); + if (ran.get(index) + && original.startsWith("(") + && original.endsWith(")") + && unwrap(original, "(", ")") != null + && !display.startsWith("(")) { + display = "(" + display + ")"; + } + shown = shown.replace(TOKEN_PREFIX + index, display); + } + return shown.trim(); } private HashMap environment(MappingProcessor processor) { return new HashMap<>() { private final Set reading = new HashSet<>(); - private final HashMap resolved = new HashMap<>(); @Override public Object get(Object key) { @@ -355,16 +1064,11 @@ public Object get(Object key) { if (index < 0 || index >= texts.size()) { return super.get(key); } - if (resolved.containsKey(name)) { - return resolved.get(name); - } if (!reading.add(name)) { throw new IllegalStateException("Cyclic boolean side " + name); } try { - boolean value = read(processor, texts.get(index)); - resolved.put(name, value); - return value; + return read(processor, index); } finally { reading.remove(name); } @@ -372,22 +1076,66 @@ public Object get(Object key) { }; } - private static boolean read(MappingProcessor processor, String raw) { - String side = raw == null ? "" : raw.trim(); + private Object read(MappingProcessor processor, int index) { + String side = texts.get(index) == null ? "" : texts.get(index).trim(); + boolean asBoolean = booleanContext.get(index); if (side.isEmpty()) { + mark(index, ""); return false; } String expression = unwrap(side, "<{", "}>"); if (expression == null) { expression = unwrap(side, "~[~{", "}~]~"); } - if (expression != null && containsOperator(expression)) { - return evaluate(processor, expression); + if (expression != null) { + try { + String pasted = processor.resolveWholeText(side); + String shown = pasted == null ? "" : pasted.trim(); + mark(index, shown); + if (asBoolean) { + if (pasted == null || pasted.isBlank() || isWholeUnresolvedReference(pasted)) { + return false; + } + return isTruthy(pasted); + } + return pasted == null ? "" : pasted; + } catch (RuntimeException ex) { + if (!ran.get(index)) { + mark(index, side); + } + throw ex; + } + } + if (containsOperator(side) || hasTernary(side) || endsWithBooleanMarker(side)) { + EvalTrace inner = new EvalTrace(); + try { + Object value = evaluateValue(processor, side, inner); + mark(index, inner.picture == null ? side : inner.picture); + return asBoolean ? isTruthy(value) : value; + } catch (RuntimeException ex) { + mark(index, inner.picture == null ? side : inner.picture); + throw ex; + } } - if (containsOperator(side)) { - return evaluate(processor, side); + if (asBoolean) { + return pasteAndBoolean(processor, side, this, index); + } + String pasted = processor.resolveWholeText(side); + String shown = pasted == null ? "" : pasted.trim(); + mark(index, shown); + if ((containsOperator(shown) || hasTernary(shown) || endsWithBooleanMarker(shown)) + && !shown.equals(side)) { + EvalTrace inner = new EvalTrace(); + try { + Object value = evaluateValue(processor, pasted, inner); + mark(index, inner.picture == null ? shown : inner.picture); + return value; + } catch (RuntimeException ex) { + mark(index, inner.picture == null ? shown : inner.picture); + throw ex; + } } - return pasteAndBoolean(processor, side); + return eval(pasted == null ? "" : pasted, processor); } } } diff --git a/src/main/java/tools/dscode/common/mappings/MappingProcessor.java b/src/main/java/tools/dscode/common/mappings/MappingProcessor.java index a2e0e1a1c..2af72bde2 100644 --- a/src/main/java/tools/dscode/common/mappings/MappingProcessor.java +++ b/src/main/java/tools/dscode/common/mappings/MappingProcessor.java @@ -680,13 +680,12 @@ private String replaceLazyBooleanExpressions( open + INTERNAL_EXPRESSION_OPEN.length(), endExclusive - INTERNAL_EXPRESSION_CLOSE.length()); String restored = restoreExpressionBody(internalBody, parsedObj, bookends); - if (!LazyBooleanSides.containsOperator(restored)) { - output.append(input, cursor, endExclusive); - cursor = endExclusive; - continue; - } output.append(input, cursor, open); - output.append(LazyBooleanSides.evaluate(this, restored)); + output.append(LazyBooleanSides.evaluateReference( + this, + restored, + bookends.expressionOpen(), + bookends.expressionClose())); cursor = endExclusive; } return output.toString(); @@ -717,13 +716,8 @@ private String replaceRawTildeLazyExpressions( String body = restored.length() >= open.length() + close.length() ? restored.substring(open.length(), restored.length() - close.length()) : restored; - if (!LazyBooleanSides.containsOperator(body)) { - output.append(input, cursor, end); - cursor = end; - continue; - } output.append(input, cursor, start); - output.append(LazyBooleanSides.evaluate(this, body)); + output.append(LazyBooleanSides.evaluateReference(this, body, open, close)); cursor = end; } return output.toString(); diff --git a/src/main/java/tools/dscode/common/mappings/ParsingMap.java b/src/main/java/tools/dscode/common/mappings/ParsingMap.java index 5acfc2c0d..46fe4f895 100644 --- a/src/main/java/tools/dscode/common/mappings/ParsingMap.java +++ b/src/main/java/tools/dscode/common/mappings/ParsingMap.java @@ -1285,9 +1285,7 @@ private static boolean isLazyBooleanExpression(String body) { return false; } String trimmed = body.trim(); - return trimmed.startsWith("{") - && trimmed.endsWith("}") - && LazyBooleanSides.containsOperator(trimmed); + return trimmed.startsWith("{") && trimmed.endsWith("}"); } private static Reference lazyBooleanReference( @@ -1306,7 +1304,7 @@ private static Reference lazyBooleanReference( String body = input.substring( index + open.length() - 1, end - close.length() + 1); - if (!LazyBooleanSides.containsOperator(body)) { + if (!body.startsWith("{") || !body.endsWith("}")) { return null; } return new Reference(index, end - 1, body); 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 6435e468b..21423d358 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 @@ -22,7 +22,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | -| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation; `LazyBooleanSides`; `MappingProcessor.replaceLazyBooleanExpressions`. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only. A skipped IF, ELSE-IF, or ELSE branch is not resolved. Inside one expression, `&&` and `||` paste a side only when that side runs; a single `|` still pastes both sides | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `lazy-branch-resolution.feature`; `lazy-boolean-sides.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest`; `LazyBranchResolutionTest`; `LazyBooleanSideTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | +| Nested steps/block conditionals | search `Nested`, `Conditional`, `Block`, `Condition` in core implementation; `LazyBooleanSides`; `MappingProcessor.replaceLazyBooleanExpressions`. An IF / THEN / ELSE-IF / ELSE branch inherits the step data table or doc string. A branch DT marker overrides that branch. A marker peeled from the whole line returns on the last branch only. A skipped IF, ELSE-IF, or ELSE branch is not resolved. Inside one expression, `&&`, `||`, and `?:` paste a component only when that component runs; a trailing `?` is still the boolean marker; a single `|` still pastes both sides. Each expression logs one info line with taken parts filled in and skipped parts left as written | `nested-and-block-conditionals.feature`; `syntax-since-2.1.14.feature`; `lazy-branch-resolution.feature`; `lazy-boolean-sides.feature`; `ConditionalBranchArgumentTest`; `ConditionalArgumentPickleTest`; `LazyBranchResolutionTest`; `LazyBooleanSideTest` | `docs/nested-steps.md`; `docs/block-conditionals.md` | | Author-facing log and assert steps | `LogAndAssertSteps.java`; `AssertionClauseSplitter.java`; `MessageAndLoggingSteps.java`; `DynamicSteps.executeDynamicStep`; `LogForwarder`; search `ASSERT:`, `SOFT ASSERT:`, `TRACE:`, `FAIL:` | `log-and-assert-steps.feature`; `AssertionClauseSplitterChecks.java`; `LogAndAssertStepsChecks.java`; `AssertionClauseSplitterTest.java` | `docs/log-and-assert-steps.md`; `docs/dynamic-steps.md`; `docs/block-conditionals.md` | | Component scenarios/reusable RUN/selectors/markers | `ModularScenarios.java`; `RunRowCondition.java`; `CalledFeatureBackground.java`; `ScenarioStep.java`; `ScenarioStepData.java`; `StepBase.java`; `StepExtension.java`; `CurrentScenarioState.java`; `CucumberScanUtil.java`; search `finalizerSteps`, `RunSelection`, `RunIf`, `RunBackground` | `component-scenarios.feature`; `reusable-scenario-selection.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; `run-row-gates.feature`; `SyntaxProofSteps.java`; marker features | `docs/component-scenarios.md`; `docs/service-call-scenarios.md`; `docs/data-values-and-elements.md` | | Service-call definitions/execution | `ServiceCallSteps.java`; `ModularScenarios.java`; `StepExtension.java`; `CurrentScenarioState.java`; `RestAssuredUtil.java`; mapping classes; `maven-consumer-project/src/test/resources/calls` | `service-call-execution.feature`; `run-step-parameter-variations.feature`; `syntax-since-2.1.14.feature`; reusable selection/parameter features; local server support | `docs/service-call-scenarios.md`; `docs/component-scenarios.md`; `docs/mapping-and-templating.md` | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md b/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md index 58ffcfaa6..541939c21 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/block-conditionals.md @@ -61,6 +61,10 @@ Expression parts are evaluated independently. They do not inherit a subject or c `&&` and `||` read each side only when that side runs. A skipped side is not pasted and is not parsed, so a missing reference, a `$` call, a `file:` reference, or bad syntax on that side does not run. A blank side is false. A single `|` is not a short-circuit and still reads both sides. A taken comparison still pastes saved text, so ` > 5` with A saved as `6` is the source `6 > 5`. +`condition ? whenTrue : whenFalse` uses the same rule. The condition runs first. Only the arm that runs is pasted and evaluated. The other arm stays as written, so a missing reference, a `$` call, a `file:` reference, or bad syntax there does not throw. A taken arm still pastes and can throw. Nested `&&`, `||`, or `?:` inside a skipped arm or side is not pasted. A `?` at the very end of an expression is still the boolean marker (evaluate as boolean, then drop the `?`), not this operator. `!`, comparisons, math, a single `|`, and function arguments still run. + +Each expression reference logs one info line after it finishes. A part that ran is shown with its map references filled in. A part that never ran is shown as written. Then the line shows the result, for example `<{ || }> -> yes || -> true`. A plain expression with nothing to skip logs the original text, the filled-in text, and the result. If evaluation throws, an info line starting with `evaluation failed` names the original expression, shows that same picture, and includes the error. The error still propagates, and it still names the text that failed. + ## Inline branch chains Use `THEN:` when the result fits on one line: From 203fc011f6b810498ecdf27e4834672c7564caf8 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Sun, 4 Oct 2026 10:03:50 +0000 Subject: [PATCH 19/23] Bottom line: a private run keeps its own config copy, a long Workbench load names itself off the window thread, and a split can take the window. --- AGENTS.md | 2 +- docs/agent/feature-map.md | 6 +- docs/agent/repository-index.md | 8 + docs/config-files-and-resource-mapping.md | 4 + docs/configuration.md | 2 + docs/consumer-agent-guide.md | 2 +- docs/pickleball-workbench.md | 4 +- .../dscode/control/protocol/RunConfigs.java | 76 ++++++ .../dscode/control/protocol/RunView.java | 2 +- .../control/protocol/RunConfigsTest.java | 28 ++ pickleball-workbench/AGENTS.md | 8 +- .../dscode/workbench/ui/ConfigTabModel.java | 99 +++++++ .../workbench/ui/FeaturePickerPanel.java | 2 +- .../tools/dscode/workbench/ui/LongWork.java | 80 ++++++ .../dscode/workbench/ui/WorkbenchFrame.java | 207 +++++++++++++-- .../dscode/workbench/ui/WorkbenchTheme.java | 9 + .../workbench/ui/ConfigTabModelTest.java | 64 +++++ .../dscode/workbench/ui/LongWorkTest.java | 98 +++++++ .../coordination/AgentCoordination.java | 22 +- .../common/coordination/RunConfigCopy.java | 245 ++++++++++++++++++ .../dscode/common/mappings/ParsingMap.java | 25 +- .../dscode/testengine/PickleballRunner.java | 21 +- .../pickleball/guidance/AGENT-GUIDE.md | 2 +- .../guidance/docs/agent/feature-map.md | 6 +- .../guidance/docs/agent/repository-index.md | 8 + .../docs/config-files-and-resource-mapping.md | 4 + .../pickleball/guidance/docs/configuration.md | 2 + .../guidance/docs/consumer-agent-guide.md | 2 +- .../guidance/docs/pickleball-workbench.md | 4 +- .../coordination/RunConfigIsolationTest.java | 121 +++++++++ 30 files changed, 1113 insertions(+), 50 deletions(-) create mode 100644 pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunConfigs.java create mode 100644 pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunConfigsTest.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/ConfigTabModel.java create mode 100644 pickleball-workbench/src/main/java/tools/dscode/workbench/ui/LongWork.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/ui/ConfigTabModelTest.java create mode 100644 pickleball-workbench/src/test/java/tools/dscode/workbench/ui/LongWorkTest.java create mode 100644 src/main/java/tools/dscode/common/coordination/RunConfigCopy.java create mode 100644 src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java diff --git a/AGENTS.md b/AGENTS.md index 5f1ac8db3..cebd24800 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,7 +185,7 @@ The published Pickleball JAR embeds the completed controller-only Workbench JAR The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. -The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. A private `.pickleball/runs//` directory is only for an agent or Workbench run. A normal `mvn test` does not create it, does not set `user-data-dir`, and still writes both HTML reports. Parallel workers in one private run each get `browser-profile/`. `pkb_compositeReport=false` and `pkb_scenarioReport=false` are the agent and Workbench defaults. Do not change the `pkb_parallel` default. Isolate stays one scenario. Do not treat resolve-runvars as Discover's environment. Trust the post-Discover run record. Sealed input is `pkb_overriderunvars` only. +The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. A private `.pickleball/runs//` directory is only for an agent or Workbench run. A normal `mvn test` does not create it, does not copy configs, does not set `user-data-dir`, and still writes both HTML reports and still reads the project configs. A private run copies the project configs into `config/` once and reads and writes only that copy. The Config tab beside Mapping is a view of that copy. Sync, run start, and other long loads stay off the event thread and show a banner that names the work or the error. Split panes drag so one panel can take almost the whole window. Parallel workers in one private run each get `browser-profile/`. `pkb_compositeReport=false` and `pkb_scenarioReport=false` are the agent and Workbench defaults. Do not change the `pkb_parallel` default. Isolate stays one scenario. Do not treat resolve-runvars as Discover's environment. Trust the post-Discover run record. Sealed input is `pkb_overriderunvars` only. ## Build and validation diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 21423d358..567b211cf 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -6,9 +6,9 @@ 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; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `ConfigTabModel`; `LongWork`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `ConfigTabModelTest`; `LongWorkTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -18,7 +18,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `RunConfigCopy`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index abe6d10b6..f6f0bcf54 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -153,6 +153,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/control/ControlRuntimeAspect.java` - `src/main/java/tools/dscode/common/control/ControlValueEvent.java` - `src/main/java/tools/dscode/common/coordination/AgentCoordination.java` +- `src/main/java/tools/dscode/common/coordination/RunConfigCopy.java` - `src/main/java/tools/dscode/common/CoreSteps.java` - `src/main/java/tools/dscode/common/dataelements/CollectionDataAdapter.java` - `src/main/java/tools/dscode/common/dataelements/CollectionQueryEngine.java` @@ -409,6 +410,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java` - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` +- `src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` @@ -520,11 +522,13 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballVersion.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunConfigs.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlBridgeStepResolutionTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlProtocolStepSeedTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java` +- `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunConfigsTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java` ## Pickleball Workbench module @@ -577,7 +581,9 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/ConfigTabModel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/LongWork.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/RunVarOverridePanel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java` @@ -632,6 +638,8 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/ConfigTabModelTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/LongWorkTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/RunVarOverridePanelTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/web/WorkbenchWebResourceTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` diff --git a/docs/config-files-and-resource-mapping.md b/docs/config-files-and-resource-mapping.md index 9b3be1b15..7a9522c08 100644 --- a/docs/config-files-and-resource-mapping.md +++ b/docs/config-files-and-resource-mapping.md @@ -55,6 +55,10 @@ The controlled run uses the default `configs` resource root because the blank va Named browser yaml files under the configured config path remain the local override, including headed `CHROME.yaml`. When `CHROME_HEADLESS` is absent from that mapping, Pickleball fills it from the JAR resource `META-INF/pickleball/configs/CHROME_HEADLESS.yaml` so agents can set `pkb_browser=CHROME_HEADLESS` without copying yaml. See [Execution Configuration](configuration.md). +## Per-run copy + +An agent run or a Workbench run copies that config tree into `.pickleball/runs//config` when the run starts and then reads only the copy. Edits, including from the Workbench Config tab, write the copy and do not write the project files. A second run has its own copy. A normal test does not copy and still loads `pkb_configpath` from the project. The stored run profile keeps the project `pkb_configpath`. It does not record the run directory. + ## Initialization order Run configuration is resolved before the final config source is bound: diff --git a/docs/configuration.md b/docs/configuration.md index c792d4e82..905a70c06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,6 +112,8 @@ Different consumer agents can run different tests at the same time. The shared f A private `.pickleball/runs//` directory is only for an agent run or a Workbench run (`-Dpkb_run_id`, `-Dpkb_agent_id`, or a Workbench worker). A normal `mvn test` does not open that folder, does not set Chrome or Edge `user-data-dir`, and writes the composite HTML report to `reports/cucumber-report.html`. Diagnostic packs and scratch stay on their 2.1.13 paths for a normal test. The short log and inbox stay where they are. +When that private run starts, Pickleball copies the consumer project's configs into `.pickleball/runs//config` and reads only that copy. Browser yaml and the other files under the resolved `pkb_configpath` are in the copy, including a bundled `CHROME_HEADLESS.yaml` when the project does not already have one. The copy is taken once. Starting the same run again does not overwrite it, so an edit made for that run stays. A second run gets its own copy from the project files and does not see the first run's edits. Neither run writes the project configs. `pkb_configpath` in the run profile stays the project path. The run directory is not stored there. A normal `mvn test` does not copy anything and keeps reading the project configs. + An agent or Workbench run that does not pass a run id gets one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. Reports, diagnostic packs, scratch, and a headless Workbench session file for that private run live under `.pickleball/runs//`. Local Chrome and Edge in one run do not share one profile. Each parallel worker gets `browser-profile/`. Remote drivers are unchanged. Options that already contain `user-data-dir` are left alone. Two runs never write the same output folder or the same live session file. diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index d70c3b486..33510ba41 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -39,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder or a live session. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder, a live session, or a config copy. Each private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. ### Live isolation loop diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 9b2c0fed4..7771f140e 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -81,7 +81,9 @@ Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` sta ## The window is a client -Every action that changes the open session already exists as a launcher command or a command-queue command. The Swing window calls that command. It does not keep a second implementation. +Every action that changes the open session already exists as a launcher command or a command-queue command. The Swing window calls that command. It does not keep a second implementation. Switching the Config tab is only a view change. It shows `.pickleball/runs//config`, the same copy the run reads. Save writes that copy and does not write the project configs. An agent changes those files through the same run directory. + +Project sync, starting a run, and any other long load run off the Swing event thread. While that work is in progress the window stays usable and a banner names it (`Syncing the project`, `Starting the run`, `Loading the project`, or the other long load). The banner clears when the work finishes. A failure stays on that banner and names the error. Every split pane can be dragged so one side takes almost the whole window. | Window action | Command | | --- | --- | diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunConfigs.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunConfigs.java new file mode 100644 index 000000000..ba54498de --- /dev/null +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunConfigs.java @@ -0,0 +1,76 @@ +package tools.dscode.control.protocol; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +/** + * The config files of one run, under {@code .pickleball/runs//config}. + * Agents and the Workbench Config tab both read and write this directory. + * Nothing here writes the consumer project's own configs. + */ +public final class RunConfigs { + public static final String DIRECTORY = "config"; + + private RunConfigs() { + } + + public static Path directory(Path project, String runId) { + return RunView.runDirectory(project, runId).resolve(DIRECTORY); + } + + public static List files(Path project, String runId) { + Path root = directory(project, runId); + if (!Files.isDirectory(root)) return List.of(); + try (Stream walk = Files.walk(root)) { + return walk.filter(Files::isRegularFile) + .map(path -> root.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/')) + .sorted() + .toList(); + } catch (IOException ignored) { + return List.of(); + } + } + + public static String read(Path project, String runId, String relative) { + Path file = resolve(directory(project, runId), relative); + if (!Files.isRegularFile(file)) { + throw new IllegalArgumentException("No config file " + relative + " in this run."); + } + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new IllegalStateException("Could not read " + file + ": " + failure.getMessage(), failure); + } + } + + public static void write(Path project, String runId, String relative, String text) { + Path root = directory(project, runId); + Path file = resolve(root, relative); + try { + Files.createDirectories(file.getParent() == null ? root : file.getParent()); + Files.writeString(file, text == null ? "" : text, StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new IllegalStateException("Could not write " + file + ": " + failure.getMessage(), failure); + } + } + + static Path resolve(Path root, String relative) { + if (relative == null || relative.isBlank()) { + throw new IllegalArgumentException("A config file name is required."); + } + String cleaned = relative.trim().replace('\\', '/'); + if (cleaned.startsWith("/") || cleaned.contains("..")) { + throw new IllegalArgumentException("A config path must stay inside this run's config copy: " + relative); + } + Path rootAbs = root.toAbsolutePath().normalize(); + Path target = rootAbs.resolve(cleaned).normalize(); + if (!target.startsWith(rootAbs)) { + throw new IllegalArgumentException("A config path must stay inside this run's config copy: " + relative); + } + return target; + } +} diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java index d5ac0cf0e..f9c258314 100644 --- a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java @@ -86,7 +86,7 @@ public static Loaded load(Path project, String runId) { recordText, List.copyOf(logs), reports(directory), - regularFiles(directory.resolve("config")) + regularFiles(directory.resolve(RunConfigs.DIRECTORY)) ); } diff --git a/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunConfigsTest.java b/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunConfigsTest.java new file mode 100644 index 000000000..0371f0cc4 --- /dev/null +++ b/pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunConfigsTest.java @@ -0,0 +1,28 @@ +package tools.dscode.control.protocol; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RunConfigsTest { + @TempDir + Path project; + + @Test + void writesStayInsideTheRunCopy() throws Exception { + Path file = RunConfigs.directory(project, "run-a").resolve("nested/URL.yaml"); + RunConfigs.write(project, "run-a", "nested/URL.yaml", "home: a\n"); + assertEquals("home: a\n", Files.readString(file)); + assertEquals("home: a\n", RunConfigs.read(project, "run-a", "nested/URL.yaml")); + assertTrue(RunConfigs.files(project, "run-a").contains("nested/URL.yaml")); + assertThrows(IllegalArgumentException.class, () -> + RunConfigs.write(project, "run-a", "../URL.yaml", "nope\n")); + assertTrue(Files.notExists(project.resolve(".pickleball/runs/URL.yaml"))); + } +} diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index fdde3479c..af2f39c8d 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -70,7 +70,7 @@ The UI is player-style and execution-oriented. Its primary layout is: ```text left rail: scenario name/tag filters + results; optional feature-file filter; collapsible Sealed RunVars panel center: Live Gherkin text editor + compact Step Editor / Command -right: Mapping | Terminal | Explorer | Report +right: Mapping | Config | Terminal | Explorer | Report ``` The Sealed RunVars panel is JDK/Workbench-only: it displays a map and writes LastDiscoverSnapshot `sealed=true` for the next worker `-Dpkb_overriderunvars=`. It must not import `PKB_props`, `PickleballProfiles`, or Pickleball core. Panel edits do not mutate an in-flight worker. Unused, it changes no behavior. The panel shows `pkb_compositereport` and `pkb_scenarioreport`. Agent and Workbench runs default both to false. A normal test still writes both HTML reports. An explicit true writes that report again. @@ -89,9 +89,11 @@ The Step Editor has two play actions: **Step** executes only the editor text thr Buffered Play / From Here / add-and-continue now execute through the existing live `executeStep` contract. Swing remains a presentation adapter: it sends displayed Gherkin unchanged and never owns a second worker manager, Mapping implementation, or Pickleball runtime. -The Mapping tab must not hard-code NodeMap names. It is one current-ParsingMap NodeMap selector plus a structured property tree. Typed edits go through `mappingPut`; renames/object replacement use `mappingRestore`. Do not create a fake ParsingMap in Swing or WebView. +The Mapping tab must not hard-code NodeMap names. It is one current-ParsingMap NodeMap selector plus a structured property tree. Typed edits go through `mappingPut`; renames/object replacement use `mappingRestore`. Do not create a fake ParsingMap in Swing or WebView. The Config tab beside it is only a view of the open run's `config/` copy (`ConfigTabModel` / protocol `RunConfigs`). Switching to it does not sync or start a run. Save writes that copy and does not write the project configs. -The Terminal tab tails the existing worker stdout/stderr files and filters TRACE–ERROR. Do not implement it by redirecting MCP stdout or inventing log lines. Explorer is a two-panel replay of retained Pickleball runs: an indented execution tree on the left (indent = call depth; no return arrows, no Mermaid, no live graph) and screenshot / honest gap / Gherkin / `source.path:line` / definition / INFO+ on the right. It also tails in-progress isolate `events.jsonl` (`completion` `IN_PROGRESS`) and live `workbench_events` as a `live-isolate` stream. Color is status at the playhead. Click seeks; Ctrl+click / Open / double-click is `workbench_go` peek of the pack-local `source/files/` copy and must not replace the live buffer. Pause/Stop/explorer rewind do not rewind browser, Mapping, or services. Report is the fourth right tab over the same `investigation.json` as portable `report.html` (indented list, Gherkin/business first, no Mermaid). Do not populate either tab with fake production data. +Blocking synchronization, catalog loads, worker start, and other long startup work must not run on the Swing event thread (`LongWork`, thread name `pickleball-workbench-load`). A banner names `Syncing the project`, `Starting the run`, `Loading the project`, or the other long load, then clears. A failure stays on that banner and names the error. Do not rely on the footer line for that status. Every `JSplitPane` is dragged without a large minimum size, so one panel can take almost the whole window. + +The Terminal tab tails the existing worker stdout/stderr files and filters TRACE–ERROR. Do not implement it by redirecting MCP stdout or inventing log lines. Explorer is a two-panel replay of retained Pickleball runs: an indented execution tree on the left (indent = call depth; no return arrows, no Mermaid, no live graph) and screenshot / honest gap / Gherkin / `source.path:line` / definition / INFO+ on the right. It also tails in-progress isolate `events.jsonl` (`completion` `IN_PROGRESS`) and live `workbench_events` as a `live-isolate` stream. Color is status at the playhead. Click seeks; Ctrl+click / Open / double-click is `workbench_go` peek of the pack-local `source/files/` copy and must not replace the live buffer. Pause/Stop/explorer rewind do not rewind browser, Mapping, or services. Report stays the investigation tab over the same `investigation.json` as portable `report.html` (indented list, Gherkin/business first, no Mermaid). Do not populate either tab with fake production data. Picker “Play this scenario” still replaces the live Gherkin buffer. Explorer / Report / `workbench_go` from a retained run open a peek tab. Prefer pack copies. Never write. Never open framework sources from the JAR. Paths stay inside the consumer project. A missing target is a visible miss. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/ConfigTabModel.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/ConfigTabModel.java new file mode 100644 index 000000000..a4b8cb9a2 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/ConfigTabModel.java @@ -0,0 +1,99 @@ +package tools.dscode.workbench.ui; + +import tools.dscode.control.protocol.RunConfigs; + +import java.nio.file.Path; +import java.util.List; + +/** + * View of one run's config copy. Showing it only reads. Saving writes that + * copy and does not write the consumer project's configs. + */ +public final class ConfigTabModel { + private Path project; + private String runId = ""; + private boolean editable; + private String selected = ""; + private String draft = ""; + private String status = "No run is open."; + + public void show(Path project, String runId, boolean editable) { + this.project = project; + this.runId = runId == null ? "" : runId.trim(); + this.editable = editable && project != null && !this.runId.isEmpty(); + List names = files(); + if (selected.isEmpty() || !names.contains(selected)) { + selected = names.isEmpty() ? "" : names.getFirst(); + } + load(); + } + + public List files() { + if (project == null || runId.isEmpty()) return List.of(); + return RunConfigs.files(project, runId); + } + + public void select(String relative) { + selected = relative == null ? "" : relative.trim(); + load(); + } + + public void setDraft(String text) { + draft = text == null ? "" : text; + } + + public void save() { + if (project == null || runId.isEmpty() || selected.isEmpty()) { + status = "No config file is open."; + throw new IllegalStateException(status); + } + if (!editable) { + status = "This run is read-only. The project configs were not written."; + throw new IllegalStateException(status); + } + RunConfigs.write(project, runId, selected, draft); + status = "Saved " + selected + " in this run's config copy."; + } + + public String selected() { + return selected; + } + + public String text() { + return draft; + } + + public String status() { + return status; + } + + public boolean editable() { + return editable; + } + + public String runId() { + return runId; + } + + private void load() { + if (project == null || runId.isEmpty()) { + draft = ""; + status = "No run is open."; + return; + } + if (selected.isEmpty()) { + draft = ""; + status = editable + ? "This run has no config files." + : "Read-only. This run has no config files."; + return; + } + try { + draft = RunConfigs.read(project, runId, selected); + status = (editable ? "Editing " : "Read-only ") + selected + " in this run's config copy."; + } catch (RuntimeException failure) { + draft = ""; + status = failure.getMessage(); + } + } +} 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 30e57c02e..b74cae150 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 @@ -57,7 +57,7 @@ final class FeaturePickerPanel extends JPanel { WorkbenchTheme.surface(this); setBorder(WorkbenchTheme.cardBorder()); setPreferredSize(new Dimension(300, 640)); - setMinimumSize(new Dimension(240, 320)); + setMinimumSize(new Dimension(0, 0)); add(northChrome(), BorderLayout.NORTH); add(labeled("Scenarios", scenarioList), BorderLayout.CENTER); diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/LongWork.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/LongWork.java new file mode 100644 index 000000000..d74833093 --- /dev/null +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/LongWork.java @@ -0,0 +1,80 @@ +package tools.dscode.workbench.ui; + +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Runs a long load off the caller thread and keeps a status the window can show. + * The status names the work while it runs, clears when it finishes, and names + * the error when it fails. + */ +final class LongWork { + static final String SYNCING_THE_PROJECT = "Syncing the project"; + static final String STARTING_THE_RUN = "Starting the run"; + static final String LOADING_THE_PROJECT = "Loading the project"; + static final String STOPPING_THE_RUN = "Stopping the run"; + + private final AtomicReference status = new AtomicReference<>(); + private final AtomicBoolean failure = new AtomicBoolean(); + + String status() { + return status.get(); + } + + boolean failed() { + return failure.get(); + } + + void finish() { + failure.set(false); + status.set(null); + } + + static Thread thread(Runnable runnable) { + Thread thread = new Thread(runnable, "pickleball-workbench-load"); + thread.setDaemon(true); + return thread; + } + + void start( + String name, + Supplier work, + Consumer onSuccess, + Consumer onFailure, + Executor executor, + Consumer onCallerThread + ) { + failure.set(false); + status.set(name); + executor.execute(() -> { + try { + T value = work.get(); + onCallerThread.accept(() -> { + try { + onSuccess.accept(value); + } catch (Throwable thrown) { + markFailure(name, thrown); + onFailure.accept(thrown); + } + }); + } catch (Throwable thrown) { + onCallerThread.accept(() -> { + markFailure(name, thrown); + onFailure.accept(thrown); + }); + } + }); + } + + private void markFailure(String name, Throwable thrown) { + failure.set(true); + String detail = thrown == null ? "unknown error" : thrown.getMessage(); + if (detail == null || detail.isBlank()) { + detail = thrown.getClass().getSimpleName(); + } + status.set(name + " failed: " + detail); + } +} 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 2c69cb5af..205730f55 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 @@ -62,6 +62,8 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Supplier; @@ -124,6 +126,19 @@ 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 LongWork longWork = new LongWork(); + private final ExecutorService loadExecutor = Executors.newSingleThreadExecutor(LongWork::thread); + private final JPanel workBanner = new JPanel(new BorderLayout()); + private final JLabel workBannerLabel = new JLabel(); + private final ConfigTabModel configTab = new ConfigTabModel(); + private final DefaultListModel configFileModel = new DefaultListModel<>(); + private final JList configFiles = new JList<>(configFileModel); + private final JTextArea configEditor = new JTextArea(); + private final JLabel configStatus = new JLabel("No run is open."); + private final JButton configSave = WorkbenchTheme.flatButton("Save", "Write this file in the run's config copy"); + private String viewedRunId; + private boolean viewedRunEditable; + private boolean syncingConfig; private final JPanel agentBanner = new JPanel(new BorderLayout(12, 0)); private final JLabel agentBannerLabel = new JLabel(); private final JButton takeControlButton = WorkbenchTheme.accentButton( @@ -276,7 +291,7 @@ public void windowClosing(WindowEvent event) { } }); - runStateAction("Loading project status", controller::refresh); + runLong(LongWork.LOADING_THE_PROJECT, controller::refresh); } private JMenuBar menuBar() { @@ -310,6 +325,8 @@ private JComponent topChrome() { JPanel north = new JPanel(); north.setOpaque(false); north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS)); + north.add(workBanner); + north.add(Box.createVerticalStrut(6)); north.add(agentBanner); north.add(Box.createVerticalStrut(6)); north.add(permissionBar); @@ -319,6 +336,13 @@ private JComponent topChrome() { } private void configureAgentChrome() { + workBanner.setBackground(WorkbenchTheme.ACCENT_SOFT); + workBanner.setBorder(new EmptyBorder(10, 14, 10, 14)); + workBannerLabel.setFont(workBannerLabel.getFont().deriveFont(Font.BOLD, 16f)); + workBannerLabel.setForeground(WorkbenchTheme.TEXT); + workBanner.add(workBannerLabel, BorderLayout.CENTER); + workBanner.setVisible(false); + agentBanner.setBackground(new Color(0xFE, 0xF3, 0xC7)); agentBanner.setBorder(WorkbenchTheme.cardBorder()); agentBannerLabel.setForeground(WorkbenchTheme.TEXT); @@ -545,6 +569,9 @@ private void presentRun(WindowDriver.Decision decision) { text.append("\nPlay, step, and stop apply only to the live run. Other runs are readable.\n"); runViewText.setText(text.toString()); runViewText.setCaretPosition(0); + viewedRunId = decision.loaded().runId(); + viewedRunEditable = !decision.readOnly(); + refreshConfigView(); if (rightTabs != null) { int index = rightTabs.indexOfTab("Run"); if (index >= 0) rightTabs.setSelectedIndex(index); @@ -555,13 +582,16 @@ private JComponent rightWorkspace() { rightTabs = new JTabbedPane(); rightTabs.addTab("Run", runPanel()); rightTabs.addTab("Mapping", mappingPanel()); + rightTabs.addTab("Config", configPanel()); rightTabs.addTab("Terminal", terminal); rightTabs.addTab("Explorer", diagnosticsPanel()); rightTabs.addTab("Report", reportPanel()); diagnosticTailTimer.setRepeats(true); rightTabs.addChangeListener(event -> { String title = rightTabs.getTitleAt(rightTabs.getSelectedIndex()); - if ("Explorer".equals(title) || "Diagnostic Log Explorer".equals(title)) { + if ("Config".equals(title)) { + refreshConfigView(); + } else if ("Explorer".equals(title) || "Diagnostic Log Explorer".equals(title)) { refreshDiagnostics(); if (!diagnosticTailTimer.isRunning()) diagnosticTailTimer.start(); } else { @@ -610,6 +640,85 @@ private JPanel mappingPanel() { return panel; } + private JPanel configPanel() { + JPanel panel = new JPanel(new BorderLayout(6, 6)); + panel.setBackground(WorkbenchTheme.SURFACE); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + panel.setMinimumSize(new Dimension(0, 0)); + + JLabel heading = WorkbenchTheme.heading("Run config"); + heading.setToolTipText("Edits stay in this run's config copy. Project configs are not written."); + JPanel north = new JPanel(new BorderLayout(8, 0)); + north.setOpaque(false); + north.add(heading, BorderLayout.WEST); + configSave.addActionListener(event -> saveConfigDraft()); + north.add(configSave, BorderLayout.EAST); + panel.add(north, BorderLayout.NORTH); + + configFiles.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + configFiles.addListSelectionListener(event -> { + if (event.getValueIsAdjusting() || syncingConfig) return; + String selected = configFiles.getSelectedValue(); + if (selected == null || selected.equals(configTab.selected())) return; + configTab.select(selected); + showConfigDraft(); + }); + configEditor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + configEditor.setTabSize(2); + WorkbenchTheme.styleEditor(configEditor); + JScrollPane files = new JScrollPane(configFiles); + JScrollPane editor = new JScrollPane(configEditor); + WorkbenchTheme.styleScroll(files); + WorkbenchTheme.styleScroll(editor); + files.setMinimumSize(new Dimension(0, 0)); + editor.setMinimumSize(new Dimension(0, 0)); + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, files, editor); + WorkbenchTheme.styleSplit(split); + split.setResizeWeight(0.28); + split.setDividerLocation(220); + panel.add(split, BorderLayout.CENTER); + + configStatus.setBorder(new EmptyBorder(2, 2, 2, 2)); + configStatus.setForeground(WorkbenchTheme.MUTED); + panel.add(configStatus, BorderLayout.SOUTH); + refreshConfigView(); + return panel; + } + + private void refreshConfigView() { + configTab.show(controller.projectRoot(), viewedRunId, viewedRunEditable); + syncingConfig = true; + try { + configFileModel.clear(); + for (String file : configTab.files()) configFileModel.addElement(file); + if (!configTab.selected().isEmpty()) { + configFiles.setSelectedValue(configTab.selected(), true); + } + showConfigDraft(); + } finally { + syncingConfig = false; + } + } + + private void showConfigDraft() { + configEditor.setText(configTab.text()); + configEditor.setCaretPosition(0); + configEditor.setEditable(configTab.editable() && !configTab.selected().isEmpty()); + configSave.setEnabled(configTab.editable() && !configTab.selected().isEmpty()); + configStatus.setText(configTab.status()); + } + + private void saveConfigDraft() { + configTab.setDraft(configEditor.getText()); + try { + configTab.save(); + } catch (RuntimeException failure) { + configStatus.setText(failure.getMessage() == null ? "Save failed." : failure.getMessage()); + return; + } + configStatus.setText(configTab.status()); + } + private JPanel diagnosticsPanel() { JPanel panel = new JPanel(new BorderLayout()); panel.setBackground(WorkbenchTheme.SURFACE); @@ -895,16 +1004,34 @@ private void configurePicker() { }); } - private void refreshFeatureCatalog() { + private CatalogSnapshot scanProject() { WorkbenchManifest manifest = null; try { manifest = WorkbenchManifest.read(controller.projectRoot()); } catch (RuntimeException ignored) { // The picker can still scan conventional project feature folders. } - picker.setCatalog(ConsumerFeatureCatalog.scan(controller.projectRoot(), manifest)); - diagnosticNavigator = new DiagnosticEvidenceNavigator(controller.projectRoot()); - glueIndex = JavaGlueIndex.scan(controller.projectRoot()); + return new CatalogSnapshot( + ConsumerFeatureCatalog.scan(controller.projectRoot(), manifest), + new DiagnosticEvidenceNavigator(controller.projectRoot()), + JavaGlueIndex.scan(controller.projectRoot()) + ); + } + + private void applyCatalog(CatalogSnapshot snapshot) { + picker.setCatalog(snapshot.catalog()); + diagnosticNavigator = snapshot.navigator(); + glueIndex = snapshot.glue(); + } + + private record CatalogSnapshot( + ConsumerFeatureCatalog catalog, + DiagnosticEvidenceNavigator navigator, + JavaGlueIndex glue + ) { + } + + private record LoadedProject(WorkbenchUiController.State state, CatalogSnapshot catalog) { } private void togglePicker() { @@ -2188,20 +2315,55 @@ private void runStateAction( String label, Supplier action ) { - activityLabel.setText(label + "..."); - runTask( + runLong(label, () -> new LoadedProject(action.get(), scanProject()), loaded -> { + applyState(loaded.state()); + applyCatalog(loaded.catalog()); + if (loaded.state().liveReady()) { + refreshMappingCatalog(); + controller.workerLogFiles().ifPresent(terminal::setFiles); + } + }); + } + + private void runLong(String label, Supplier action) { + runStateAction(label, action); + } + + private void runLong(String label, Supplier action, Consumer success) { + longWork.start( + label, action, - state -> { - applyState(state); - activityLabel.setText(label + " complete."); - if (state.liveReady()) { - refreshMappingCatalog(); - controller.workerLogFiles().ifPresent(terminal::setFiles); - } - refreshFeatureCatalog(); + value -> { + success.accept(value); + longWork.finish(); + publishWorkStatus(); }, - failure -> showFailure(label + " failed", failure) + failure -> publishWorkStatus(), + loadExecutor, + SwingUtilities::invokeLater ); + publishWorkStatus(); + } + + private void publishWorkStatus() { + String text = longWork.status(); + boolean show = text != null && !text.isBlank(); + workBanner.setVisible(show); + if (!show) { + revalidate(); + repaint(); + return; + } + workBannerLabel.setText(text); + if (longWork.failed()) { + workBanner.setBackground(new Color(0xFE, 0xE2, 0xE2)); + workBannerLabel.setForeground(WorkbenchTheme.DANGER); + } else { + workBanner.setBackground(WorkbenchTheme.ACCENT_SOFT); + workBannerLabel.setForeground(WorkbenchTheme.TEXT); + } + revalidate(); + repaint(); } private void applyState(WorkbenchUiController.State state) { @@ -2760,6 +2922,7 @@ private JComponent advancedOverridesPanel() { new JScrollPane(source), new JScrollPane(output) ); + WorkbenchTheme.styleSplit(split); split.setResizeWeight(0.55); panel.add(split, BorderLayout.CENTER); return panel; @@ -2988,27 +3151,27 @@ public WorkbenchSaveResult presentSave() { @Override public void syncProject() { - runStateAction("Synchronizing project", controller::synchronize); + runStateAction(LongWork.SYNCING_THE_PROJECT, controller::synchronize); } @Override public void refreshStatus() { - runStateAction("Refreshing status", controller::refresh); + runStateAction(LongWork.LOADING_THE_PROJECT, controller::refresh); } @Override public void startWorker() { - runStateAction("Starting worker", controller::startWorker); + runStateAction(LongWork.STARTING_THE_RUN, controller::startWorker); } @Override public void restartWorker() { - runStateAction("Restarting worker", controller::restartWorker); + runStateAction(LongWork.STARTING_THE_RUN, controller::restartWorker); } @Override public void stopWorker() { - runStateAction("Stopping worker", controller::stopWorker); + runStateAction(LongWork.STOPPING_THE_RUN, controller::stopWorker); } @Override diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java index e2627f88e..39c3dc156 100644 --- a/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java +++ b/pickleball-workbench/src/main/java/tools/dscode/workbench/ui/WorkbenchTheme.java @@ -149,6 +149,15 @@ static void styleSplit(JSplitPane split) { split.setContinuousLayout(true); split.setDividerSize(8); split.setBackground(BACKGROUND); + // Minimum sizes are what stop a drag. Zero them so either side can + // take almost the whole window. + relax(split.getLeftComponent()); + relax(split.getRightComponent()); + relax(split); + } + + static void relax(java.awt.Component component) { + if (component != null) component.setMinimumSize(new Dimension(0, 0)); } static void surface(JComponent component) { diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/ConfigTabModelTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/ConfigTabModelTest.java new file mode 100644 index 000000000..ccd32f4aa --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/ConfigTabModelTest.java @@ -0,0 +1,64 @@ +package tools.dscode.workbench.ui; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.control.protocol.RunConfigs; + +import java.nio.charset.StandardCharsets; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigTabModelTest { + @TempDir + Path project; + + @Test + void theConfigTabReadsAndSavesOnlyThatRunsCopy() throws Exception { + Path projectFile = project.resolve("src/test/resources/configs/CHROME.yaml"); + Files.createDirectories(projectFile.getParent()); + Files.writeString(projectFile, "marker: project\n", StandardCharsets.UTF_8); + Files.createDirectories(RunConfigs.directory(project, "run-a")); + Files.createDirectories(RunConfigs.directory(project, "run-b")); + Files.writeString(RunConfigs.directory(project, "run-a").resolve("CHROME.yaml"), "marker: project\n"); + Files.writeString(RunConfigs.directory(project, "run-a").resolve("URL.yaml"), "home: a\n"); + Files.writeString(RunConfigs.directory(project, "run-b").resolve("CHROME.yaml"), "marker: project\n"); + String projectBefore = Files.readString(projectFile); + + ConfigTabModel tab = new ConfigTabModel(); + tab.show(project, "run-a", true); + assertEquals(List.of("CHROME.yaml", "URL.yaml"), tab.files()); + tab.select("CHROME.yaml"); + assertTrue(tab.text().contains("marker: project")); + assertEquals(projectBefore, Files.readString(projectFile)); + + tab.setDraft("marker: from-tab\n"); + tab.save(); + assertTrue(tab.status().contains("Saved CHROME.yaml")); + assertTrue(Files.readString(RunConfigs.directory(project, "run-a").resolve("CHROME.yaml")).contains("from-tab")); + assertEquals(projectBefore, Files.readString(projectFile)); + assertTrue(Files.readString(RunConfigs.directory(project, "run-b").resolve("CHROME.yaml")).contains("marker: project")); + assertFalse(Files.readString(RunConfigs.directory(project, "run-b").resolve("CHROME.yaml")).contains("from-tab")); + + ConfigTabModel other = new ConfigTabModel(); + other.show(project, "run-b", true); + other.select("CHROME.yaml"); + assertFalse(other.text().contains("from-tab")); + assertTrue(other.text().contains("marker: project")); + + tab.show(project, "run-a", false); + tab.select("CHROME.yaml"); + tab.setDraft("marker: read-only-edit\n"); + assertThrows(IllegalStateException.class, tab::save); + assertTrue(Files.readString(RunConfigs.directory(project, "run-a").resolve("CHROME.yaml")).contains("from-tab")); + assertEquals(projectBefore, Files.readString(projectFile)); + assertThrows(IllegalArgumentException.class, () -> + RunConfigs.write(project, "run-a", "../src/test/resources/configs/CHROME.yaml", "marker: escaped\n")); + assertEquals(projectBefore, Files.readString(projectFile)); + } +} diff --git a/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/LongWorkTest.java b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/LongWorkTest.java new file mode 100644 index 000000000..55f2dea1c --- /dev/null +++ b/pickleball-workbench/src/test/java/tools/dscode/workbench/ui/LongWorkTest.java @@ -0,0 +1,98 @@ +package tools.dscode.workbench.ui; + +import org.junit.jupiter.api.Test; + +import javax.swing.JPanel; +import javax.swing.JSplitPane; +import java.awt.Dimension; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LongWorkTest { + @Test + void longLoadsRunOffTheCallerThreadAndNameTheStatus() throws Exception { + LongWork work = new LongWork(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + AtomicReference worker = new AtomicReference<>(); + AtomicReference statusWhileApplying = new AtomicReference<>(); + + work.start( + LongWork.SYNCING_THE_PROJECT, + () -> { + worker.set(Thread.currentThread().getName()); + started.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting to finish the load"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + return "done"; + }, + value -> { + statusWhileApplying.set(work.status()); + assertFalse(work.failed()); + work.finish(); + finished.countDown(); + }, + failure -> finished.countDown(), + command -> LongWork.thread(command).start(), + Runnable::run + ); + + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertEquals("pickleball-workbench-load", worker.get()); + assertFalse(worker.get().equals(Thread.currentThread().getName())); + assertEquals(LongWork.SYNCING_THE_PROJECT, work.status()); + assertFalse(work.failed()); + release.countDown(); + assertTrue(finished.await(5, TimeUnit.SECONDS)); + assertEquals(LongWork.SYNCING_THE_PROJECT, statusWhileApplying.get()); + assertNull(work.status()); + assertEquals("pickleball-workbench-load", LongWork.thread(() -> { }).getName()); + } + + @Test + void aFailureStaysVisibleAndNamesTheError() throws Exception { + LongWork work = new LongWork(); + CountDownLatch finished = new CountDownLatch(1); + work.start( + LongWork.STARTING_THE_RUN, + () -> { + throw new IllegalStateException("port in use"); + }, + value -> finished.countDown(), + failure -> finished.countDown(), + command -> LongWork.thread(command).start(), + Runnable::run + ); + assertTrue(finished.await(5, TimeUnit.SECONDS)); + assertTrue(work.failed()); + assertEquals("Starting the run failed: port in use", work.status()); + } + + @Test + void splitPanesDropTheirMinimumSoEitherSideCanTakeTheWindow() { + JPanel left = new JPanel(); + left.setMinimumSize(new Dimension(400, 320)); + JPanel right = new JPanel(); + right.setMinimumSize(new Dimension(500, 320)); + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, left, right); + WorkbenchTheme.styleSplit(split); + assertEquals(new Dimension(0, 0), left.getMinimumSize()); + assertEquals(new Dimension(0, 0), right.getMinimumSize()); + assertEquals(0, split.getMinimumSize().width); + assertEquals(0, split.getMinimumSize().height); + assertTrue(split.isContinuousLayout()); + } +} diff --git a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java index 54a2191e2..90c45d378 100644 --- a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java +++ b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java @@ -118,6 +118,10 @@ public Path browserProfileDirectory() { return dataDirectory.resolve("browser-profile"); } + public Path configDirectory() { + return dataDirectory.resolve(RunConfigCopy.DIRECTORY_NAME); + } + public Path sessionDirectory() { return dataDirectory.resolve("session"); } @@ -239,10 +243,14 @@ public static Path runDirectory(Path project, String runId) { } public static Run begin(Path project, Request request) { - return begin(project, request, Instant.now(), null); + return begin(project, request, Instant.now(), null, null); } public static Run begin(Path project, Request request, Instant when, Appendable out) { + return begin(project, request, when, out, null); + } + + public static Run begin(Path project, Request request, Instant when, Appendable out, String configSource) { Instant clock = when == null ? Instant.now() : when; Request safe = request == null ? Request.of(null, null, null, null, null, null, null) : request; String runId = safe.runId() == null ? newRunId(clock) : requireSafeId(safe.runId(), "run id"); @@ -252,6 +260,7 @@ public static Run begin(Path project, Request request, Instant when, Appendable if (Files.isRegularFile(recordFile)) { RunRecord existing = readRecord(recordFile); ensureTree(data); + ensureConfigs(project, data, configSource); Run adopted = toRun(project, data, recordFile, existing); activate(adopted); print(out, "run-id=" + adopted.runId()); @@ -265,6 +274,7 @@ public static Run begin(Path project, Request request, Instant when, Appendable throw new IllegalArgumentException("agent id 'any' is reserved for the shared inbox."); } ensureTree(data); + ensureConfigs(project, data, configSource); String purpose = oneLine(firstNonBlank(safe.why(), safe.purpose(), "run")); RunRecord record = new RunRecord( runId, @@ -294,6 +304,10 @@ public static Run begin(Path project, Request request, Instant when, Appendable } public static Run openConsumerRun(Path project, Appendable out) { + return openConsumerRun(project, out, null); + } + + public static Run openConsumerRun(Path project, Appendable out, String configSource) { if (!launchRequestsPrivateRun()) { clearCurrent(); return null; @@ -307,7 +321,7 @@ public static Run openConsumerRun(Path project, Appendable out) { System.getProperty(PKB_props.PKB_RUN_WHY), "consumer-run" ); - Run run = begin(project, request, Instant.now(), out); + Run run = begin(project, request, Instant.now(), out, configSource); System.setProperty(PKB_props.PKB_RUN_ID, run.runId()); System.setProperty(PKB_props.PKB_AGENT_ID, run.agentId()); return run; @@ -565,6 +579,10 @@ private static Path inboxDirectory(Path project, String agentFolder) { return PickleballLocalLayout.root(project).resolve(INBOX_DIRECTORY).resolve(agentFolder); } + private static void ensureConfigs(Path project, Path data, String configSource) throws IOException { + RunConfigCopy.copyIfAbsent(project, data.resolve(RunConfigCopy.DIRECTORY_NAME), configSource); + } + private static void ensureTree(Path data) throws IOException { Files.createDirectories(data.resolve("reports")); Files.createDirectories(data.resolve("diagnostic")); diff --git a/src/main/java/tools/dscode/common/coordination/RunConfigCopy.java b/src/main/java/tools/dscode/common/coordination/RunConfigCopy.java new file mode 100644 index 000000000..e3af5f4a5 --- /dev/null +++ b/src/main/java/tools/dscode/common/coordination/RunConfigCopy.java @@ -0,0 +1,245 @@ +package tools.dscode.common.coordination; + +import tools.dscode.control.protocol.RunConfigs; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Copies a consumer project's configs into one run directory. + * The copy is created once. A later start of the same run keeps edits. + * A normal test never calls this. + */ +public final class RunConfigCopy { + public static final String DIRECTORY_NAME = RunConfigs.DIRECTORY; + public static final String BUNDLED_CHROME_HEADLESS = + "META-INF/pickleball/configs/CHROME_HEADLESS.yaml"; + + private RunConfigCopy() { + } + + public static void copyIfAbsent(Path project, Path destination, String configuredPath) throws IOException { + if (project == null || destination == null) return; + if (containsFile(destination)) return; + Files.createDirectories(destination); + Path source = resolveRoot(project, configuredPath); + if (source != null && Files.isDirectory(source)) { + copyTree(source, destination); + } else { + copyClasspath(selectSource(project, configuredPath), destination); + } + ensureBundledHeadless(destination); + } + + static String selectSource(Path project, String configuredPath) { + if (configuredPath == null) { + String discovered = discover(project); + return discovered == null || discovered.isBlank() ? "configs" : discovered.trim(); + } + return configuredPath.isBlank() ? "configs" : configuredPath.trim(); + } + + static Path resolveRoot(Path project, String configuredPath) { + String selected = selectSource(project, configuredPath); + if (selected.regionMatches(true, 0, "classpath:", 0, "classpath:".length())) { + return classpathDirectory(selected.substring("classpath:".length())); + } + if (selected.regionMatches(true, 0, "file:", 0, "file:".length())) { + Path file = fileUri(selected); + return file != null && Files.isDirectory(file) ? file.toAbsolutePath().normalize() : null; + } + Path direct = Path.of(selected); + if (direct.isAbsolute() && Files.isDirectory(direct)) { + return direct.toAbsolutePath().normalize(); + } + if (project != null) { + Path relative = project.resolve(selected).normalize(); + if (Files.isDirectory(relative)) return relative; + Path testResources = project.resolve("src/test/resources").resolve(stripSlashes(selected)).normalize(); + if (Files.isDirectory(testResources)) return testResources; + Path mainResources = project.resolve("src/main/resources").resolve(stripSlashes(selected)).normalize(); + if (Files.isDirectory(mainResources)) return mainResources; + } + return classpathDirectory(selected); + } + + private static String discover(Path project) { + String property = System.getProperty("pkb_configpath"); + if (property != null && !property.isBlank()) return property.trim(); + if (project == null) return null; + String found = null; + for (Path file : List.of( + project.resolve("pickleball.properties"), + project.resolve("pickleball_local.properties"), + project.resolve("src/test/resources/pickleball.properties"), + project.resolve("src/test/resources/pickleball_local.properties") + )) { + String value = propertyValue(file, "pkb_configpath"); + if (value != null && !value.isBlank()) found = value.trim(); + } + return found; + } + + private static String propertyValue(Path file, String key) { + if (!Files.isRegularFile(file)) return null; + try { + for (String line : Files.readAllLines(file)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) continue; + int split = indexOfSeparator(trimmed); + if (split <= 0) continue; + String name = trimmed.substring(0, split).trim(); + if (!name.equalsIgnoreCase(key)) continue; + return trimmed.substring(split + 1).trim(); + } + } catch (IOException ignored) { + return null; + } + return null; + } + + private static int indexOfSeparator(String line) { + int equals = line.indexOf('='); + int colon = line.indexOf(':'); + if (equals < 0) return colon; + if (colon < 0) return equals; + return Math.min(equals, colon); + } + + private static void copyClasspath(String configuredPath, Path destination) throws IOException { + String resource = configuredPath; + if (resource.regionMatches(true, 0, "classpath:", 0, "classpath:".length())) { + resource = resource.substring("classpath:".length()); + } + if (resource.regionMatches(true, 0, "file:", 0, "file:".length())) return; + Path fromClasspath = classpathDirectory(resource); + if (fromClasspath != null) { + copyTree(fromClasspath, destination); + return; + } + copyJarResource(stripSlashes(resource), destination); + } + + private static Path classpathDirectory(String resource) { + String name = stripSlashes(resource); + if (name.isBlank()) return null; + ClassLoader loader = loader(); + URL url; + try { + url = loader.getResource(name); + } catch (RuntimeException ignored) { + return null; + } + if (url == null || !"file".equalsIgnoreCase(url.getProtocol())) return null; + try { + Path path = Path.of(url.toURI()); + return Files.isDirectory(path) ? path.toAbsolutePath().normalize() : null; + } catch (Exception ignored) { + return null; + } + } + + private static void copyJarResource(String resource, Path destination) throws IOException { + if (resource.isBlank()) return; + URL url = loader().getResource(resource); + if (url == null || !"jar".equalsIgnoreCase(url.getProtocol())) return; + try (FileSystem fs = FileSystems.newFileSystem(jarUri(url), Map.of())) { + Path root = fs.getPath(resource); + if (Files.isDirectory(root)) copyTree(root, destination); + } catch (IOException failure) { + throw failure; + } catch (Exception ignored) { + // A missing packaged tree is not a project file to invent. + } + } + + private static void ensureBundledHeadless(Path destination) throws IOException { + if (hasBaseName(destination, "CHROME_HEADLESS")) return; + try (InputStream in = loader().getResourceAsStream(BUNDLED_CHROME_HEADLESS)) { + if (in == null) return; + Files.write(destination.resolve("CHROME_HEADLESS.yaml"), in.readAllBytes()); + } + } + + private static boolean containsFile(Path root) throws IOException { + if (!Files.isDirectory(root)) return false; + try (Stream walk = Files.walk(root)) { + return walk.anyMatch(Files::isRegularFile); + } + } + + private static boolean hasBaseName(Path root, String base) throws IOException { + if (!Files.isDirectory(root)) return false; + try (Stream walk = Files.walk(root)) { + return walk.filter(Files::isRegularFile).anyMatch(path -> { + String name = path.getFileName().toString(); + int dot = name.lastIndexOf('.'); + String stem = dot > 0 ? name.substring(0, dot) : name; + return stem.equalsIgnoreCase(base); + }); + } + } + + private static void copyTree(Path source, Path destination) throws IOException { + Path sourceRoot = source.toAbsolutePath().normalize(); + Path destRoot = destination.toAbsolutePath().normalize(); + if (sourceRoot.equals(destRoot) || destRoot.startsWith(sourceRoot) || sourceRoot.startsWith(destRoot)) { + return; + } + try (Stream walk = Files.walk(sourceRoot)) { + for (Path path : walk.toList()) { + Path relative = sourceRoot.relativize(path); + if (relative.toString().isEmpty()) continue; + Path target = destRoot.resolve(relative.toString()).normalize(); + if (!target.startsWith(destRoot)) continue; + if (Files.isDirectory(path)) { + Files.createDirectories(target); + } else if (Files.isRegularFile(path)) { + if (target.getParent() != null) Files.createDirectories(target.getParent()); + Files.copy(path, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + + private static Path fileUri(String value) { + try { + return Path.of(URI.create(value)); + } catch (Exception ignored) { + return Path.of(value.substring("file:".length())); + } + } + + private static URI jarUri(URL url) { + String text = url.toString(); + int bang = text.indexOf("!/"); + return URI.create(bang >= 0 ? text.substring(0, bang) : text); + } + + private static String stripSlashes(String value) { + String text = value == null ? "" : value.trim().replace('\\', '/'); + while (text.startsWith("/")) text = text.substring(1); + while (text.endsWith("/")) text = text.substring(0, text.length() - 1); + return text; + } + + private static ClassLoader loader() { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + return loader == null ? RunConfigCopy.class.getClassLoader() : loader; + } + + /** Visible so tests can name the run folder the same way the GUI does. */ + public static Path runConfigDirectory(Path runData) { + return runData.resolve(RunConfigs.DIRECTORY); + } +} diff --git a/src/main/java/tools/dscode/common/mappings/ParsingMap.java b/src/main/java/tools/dscode/common/mappings/ParsingMap.java index 46fe4f895..c6f6d9596 100644 --- a/src/main/java/tools/dscode/common/mappings/ParsingMap.java +++ b/src/main/java/tools/dscode/common/mappings/ParsingMap.java @@ -28,8 +28,12 @@ import tools.dscode.common.treeparsing.parsedComponents.DataElementMatch; import tools.dscode.common.treeparsing.parsedComponents.ElementMatch; +import tools.dscode.common.coordination.AgentCoordination; + import java.io.InputStream; import java.lang.reflect.Array; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -77,9 +81,7 @@ public class ParsingMap extends MappingProcessor { public static final String CHROME_HEADLESS_CONFIG_KEY = "CHROME_HEADLESS"; public static synchronized void initializeConfigs(String configuredPath) { - String path = configuredPath == null || configuredPath.isBlank() - ? DEFAULT_CONFIG_PATH - : configuredPath.trim(); + String path = configPathForCurrentRun(configuredPath); JsonNode configsNode = loadConfigs(path); ObjectNode configs; if (configsNode instanceof ObjectNode objectNode) { @@ -94,6 +96,23 @@ public static synchronized void initializeConfigs(String configuredPath) { GLOBALS.root.set(CONFIGS_MAP_ROOT, configs); } + /** + * A private run reads its own config copy. A normal test, with no current + * run, keeps the project path. + */ + static String configPathForCurrentRun(String configuredPath) { + AgentCoordination.Run run = AgentCoordination.current(); + if (run != null) { + Path copy = run.configDirectory(); + if (Files.isDirectory(copy)) { + return copy.toString(); + } + } + return configuredPath == null || configuredPath.isBlank() + ? DEFAULT_CONFIG_PATH + : configuredPath.trim(); + } + static void applyBundledBrowserDefaults(ObjectNode configs) { if (configs == null || hasConfigKeyIgnoreCase(configs, CHROME_HEADLESS_CONFIG_KEY)) { return; diff --git a/src/main/java/tools/dscode/testengine/PickleballRunner.java b/src/main/java/tools/dscode/testengine/PickleballRunner.java index 3051cbc38..657c7d44b 100644 --- a/src/main/java/tools/dscode/testengine/PickleballRunner.java +++ b/src/main/java/tools/dscode/testengine/PickleballRunner.java @@ -112,7 +112,20 @@ protected PickleballRunner() { syncCanonicalAndAliasKeys(); alignCucumberNameFilterWithFind(); syncReportPortalAliases(true); - ParsingMap.initializeConfigs(values.get(PKB_CONFIG_PATH)); + java.nio.file.Path projectRoot = PickleballLocalLayout.findProjectRoot(java.nio.file.Path.of("")); + AgentCoordination.Run coordination = null; + if (AgentCoordination.launchRequestsPrivateRun()) { + coordination = AgentCoordination.openConsumerRun( + projectRoot, + System.out, + values.get(PKB_CONFIG_PATH) + ); + } + if (coordination != null && java.nio.file.Files.isDirectory(coordination.configDirectory())) { + ParsingMap.initializeConfigs(coordination.configDirectory().toString()); + } else { + ParsingMap.initializeConfigs(values.get(PKB_CONFIG_PATH)); + } refreshRunProfile(); refreshPkbOptions(); @@ -122,17 +135,13 @@ protected PickleballRunner() { INSTANCE = this; debug("Registered singleton instance: " + getClass().getName()); - PickleballLocalStore.ensureQuietly(PickleballLocalLayout.findProjectRoot(java.nio.file.Path.of(""))); + PickleballLocalStore.ensureQuietly(projectRoot); String configuredLogLevel = get(PKB_LOGLEVEL); String effectiveLogLevel = configuredLogLevel == null || configuredLogLevel.isBlank() ? "INFO" : configuredLogLevel.trim(); LOG_LEVEL = Level.valueOf(effectiveLogLevel.toUpperCase(Locale.ROOT)); - AgentCoordination.Run coordination = AgentCoordination.openConsumerRun( - PickleballLocalLayout.findProjectRoot(java.nio.file.Path.of("")), - System.out - ); if (coordination != null) { values.put(PKB_props.PKB_RUN_ID, coordination.runId()); values.put(PKB_props.PKB_AGENT_ID, coordination.agentId()); diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index d70c3b486..33510ba41 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -39,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder or a live session. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder, a live session, or a config copy. Each private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. ### Live isolation loop 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 21423d358..567b211cf 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,9 +6,9 @@ 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; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | -| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | +| Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `ConfigTabModel`; `LongWork`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `ConfigTabModelTest`; `LongWorkTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | @@ -18,7 +18,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | -| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | +| Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `RunConfigCopy`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | | Consumer guidance export/reference snapshot | `PickleballLocalStore`; `DiagnosticCli.java`; `PickleballWorkbenchLauncher` `export-guidance`; `.pickleball/current.json`; `.pickleball/v//`; `.pickleball/open/`; `gradle/consumer-guidance.gradle`; `scripts/sync_consumer_guidance.py`; `maven-consumer-project/AGENTS.md`; `maven-consumer-project/.github/copilot-instructions.md`; search `export-guidance`, `hint`, `discover-hint`, `GUIDANCE-MANIFEST.json`, `.pickleball/investigations` | `PickleballGuidanceChecks.java`; `PickleballLocalStoreTest`; consumer guidance contract checks | `docs/consumer-agent-guide.md`; `docs/consumer-project.md` | | Consumer AI-agent pointer-eval harness | `maven-consumer-project/src/test/resources/features/agent-pointer-eval.feature`; tag `@agent-pointer-eval` (optional `@agent-pointer-eval-pass` / `@agent-pointer-eval-fail`) | opt-in mixed pass/fail; must not carry `@all`, `@regression`, or other Maven suite-profile tags; `scripts/verify_agent_contract.py` | `docs/consumer-agent-guide.md` | | Diagnostic reporting and controlled reruns | `src/main/java/tools/dscode/common/reporting/diagnostic`; `InvestigationHandoff` (`SCHEMA_VERSION` additive v2: `bottomLine`, `executionMap`, `failedStep`, `originatingCause`, `gitSuspects`, `serviceCalls`, `environmentDelta`); diagnostic aspects; `DiagnosticCli.java`; `emit-investigation`; `VisualFingerprintComparator.java`; `DiagnosticRunComparator.java`; catalog/summary `runProfile`; `SourceProvenance`; retained `source/referenced.json`, `source/files/`, filtered `source/referenced.patch.gz`, `source/live-buffer.feature` | `DiagnosticReportingChecks.java`; `Diagnostic213CompletionChecks.java`; `InvestigationHandoffChecks.java`; diagnostic features | `docs/diagnostic-reporting.md`; `docs/ai-diagnostic-reporting-plan.md`; `docs/ai-run-configuration.md`; `docs/consumer-agent-guide.md`; root `AGENTS.md` | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index abe6d10b6..f6f0bcf54 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 @@ -153,6 +153,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/control/ControlRuntimeAspect.java` - `src/main/java/tools/dscode/common/control/ControlValueEvent.java` - `src/main/java/tools/dscode/common/coordination/AgentCoordination.java` +- `src/main/java/tools/dscode/common/coordination/RunConfigCopy.java` - `src/main/java/tools/dscode/common/CoreSteps.java` - `src/main/java/tools/dscode/common/dataelements/CollectionDataAdapter.java` - `src/main/java/tools/dscode/common/dataelements/CollectionQueryEngine.java` @@ -409,6 +410,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/io/cucumber/core/gherkin/messages/ConditionalArgumentPickleTest.java` - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` +- `src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` @@ -520,11 +522,13 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballVersion.java` +- `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunConfigs.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/RunView.java` - `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/WindowDriver.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlBridgeStepResolutionTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ControlProtocolStepSeedTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/ExampleRowSelectorTest.java` +- `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunConfigsTest.java` - `pickleball-control-protocol/src/test/java/tools/dscode/control/protocol/RunWindowTest.java` ## Pickleball Workbench module @@ -577,7 +581,9 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/main/java/tools/dscode/workbench/sync/WorkbenchSyncPlanner.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogBuffer.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/terminal/WorkerLogFiles.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/ConfigTabModel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/FeaturePickerPanel.java` +- `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/LongWork.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/RunVarOverridePanel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/TerminalPanel.java` - `pickleball-workbench/src/main/java/tools/dscode/workbench/ui/web/DiagnosticExplorerHost.java` @@ -632,6 +638,8 @@ This inventory helps coding agents discover relevant files. It does not replace - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSynchronizerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/sync/WorkbenchSyncPlannerTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/terminal/WorkerLogBufferTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/ConfigTabModelTest.java` +- `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/LongWorkTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/RunVarOverridePanelTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/web/WorkbenchWebResourceTest.java` - `pickleball-workbench/src/test/java/tools/dscode/workbench/ui/WorkbenchUiControllerTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/config-files-and-resource-mapping.md b/src/main/resources/META-INF/pickleball/guidance/docs/config-files-and-resource-mapping.md index 9b3be1b15..7a9522c08 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/config-files-and-resource-mapping.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/config-files-and-resource-mapping.md @@ -55,6 +55,10 @@ The controlled run uses the default `configs` resource root because the blank va Named browser yaml files under the configured config path remain the local override, including headed `CHROME.yaml`. When `CHROME_HEADLESS` is absent from that mapping, Pickleball fills it from the JAR resource `META-INF/pickleball/configs/CHROME_HEADLESS.yaml` so agents can set `pkb_browser=CHROME_HEADLESS` without copying yaml. See [Execution Configuration](configuration.md). +## Per-run copy + +An agent run or a Workbench run copies that config tree into `.pickleball/runs//config` when the run starts and then reads only the copy. Edits, including from the Workbench Config tab, write the copy and do not write the project files. A second run has its own copy. A normal test does not copy and still loads `pkb_configpath` from the project. The stored run profile keeps the project `pkb_configpath`. It does not record the run directory. + ## Initialization order Run configuration is resolved before the final config source is bound: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index c792d4e82..905a70c06 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -112,6 +112,8 @@ Different consumer agents can run different tests at the same time. The shared f A private `.pickleball/runs//` directory is only for an agent run or a Workbench run (`-Dpkb_run_id`, `-Dpkb_agent_id`, or a Workbench worker). A normal `mvn test` does not open that folder, does not set Chrome or Edge `user-data-dir`, and writes the composite HTML report to `reports/cucumber-report.html`. Diagnostic packs and scratch stay on their 2.1.13 paths for a normal test. The short log and inbox stay where they are. +When that private run starts, Pickleball copies the consumer project's configs into `.pickleball/runs//config` and reads only that copy. Browser yaml and the other files under the resolved `pkb_configpath` are in the copy, including a bundled `CHROME_HEADLESS.yaml` when the project does not already have one. The copy is taken once. Starting the same run again does not overwrite it, so an edit made for that run stays. A second run gets its own copy from the project files and does not see the first run's edits. Neither run writes the project configs. `pkb_configpath` in the run profile stays the project path. The run directory is not stored there. A normal `mvn test` does not copy anything and keeps reading the project configs. + An agent or Workbench run that does not pass a run id gets one (`--run-id` or `-Dpkb_run_id`). An agent may pass its own id. An omitted agent id is generated and printed (`--agent` / `--agent-id` or `-Dpkb_agent_id`). Optional group, sequence, who, and why are `-Dpkb_run_group`, `-Dpkb_run_sequence`, `-Dpkb_run_who`, and `-Dpkb_run_why`. Reports, diagnostic packs, scratch, and a headless Workbench session file for that private run live under `.pickleball/runs//`. Local Chrome and Edge in one run do not share one profile. Each parallel worker gets `browser-profile/`. Remote drivers are unchanged. Options that already contain `user-data-dir` are left alone. Two runs never write the same output folder or the same live session file. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index d70c3b486..33510ba41 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -39,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder or a live session. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder, a live session, or a config copy. Each private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. ### Live isolation loop 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 9b2c0fed4..7771f140e 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 @@ -81,7 +81,9 @@ Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` sta ## The window is a client -Every action that changes the open session already exists as a launcher command or a command-queue command. The Swing window calls that command. It does not keep a second implementation. +Every action that changes the open session already exists as a launcher command or a command-queue command. The Swing window calls that command. It does not keep a second implementation. Switching the Config tab is only a view change. It shows `.pickleball/runs//config`, the same copy the run reads. Save writes that copy and does not write the project configs. An agent changes those files through the same run directory. + +Project sync, starting a run, and any other long load run off the Swing event thread. While that work is in progress the window stays usable and a banner names it (`Syncing the project`, `Starting the run`, `Loading the project`, or the other long load). The banner clears when the work finishes. A failure stays on that banner and names the error. Every split pane can be dragged so one side takes almost the whole window. | Window action | Command | | --- | --- | diff --git a/src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java b/src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java new file mode 100644 index 000000000..c9dadb66c --- /dev/null +++ b/src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java @@ -0,0 +1,121 @@ +package tools.dscode.common.coordination; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.common.mappings.ParsingMap; +import tools.dscode.control.protocol.ControlProtocol; +import tools.dscode.control.protocol.RunConfigs; +import tools.dscode.testengine.PKB_props; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RunConfigIsolationTest { + @TempDir + Path project; + + @AfterEach + void restore() { + AgentCoordination.clearCurrent(); + System.clearProperty(PKB_props.PKB_RUN_ID); + System.clearProperty(PKB_props.PKB_AGENT_ID); + System.clearProperty(PKB_props.PKB_RUN_GROUP); + System.clearProperty(PKB_props.PKB_RUN_SEQUENCE); + System.clearProperty(PKB_props.PKB_RUN_WHO); + System.clearProperty(PKB_props.PKB_RUN_WHY); + System.clearProperty(ControlProtocol.WORKBENCH_TEST_OUTPUT_ROOT_PROPERTY); + System.clearProperty("pkb_configpath"); + ParsingMap.initializeConfigs("configs"); + } + + @Test + void aPrivateRunReadsAndWritesOnlyItsOwnConfigCopy() throws Exception { + Path configs = project.resolve("src/test/resources/configs"); + Files.createDirectories(configs.resolve("jsonfiles")); + Files.writeString(configs.resolve("CHROME.yaml"), "browser: chrome\nmarker: project\n", StandardCharsets.UTF_8); + Files.writeString(configs.resolve("URL.yaml"), "home: project-home\n", StandardCharsets.UTF_8); + Files.writeString(configs.resolve("EDGE.yaml"), "browser: edge\nmarker: project-edge\n", StandardCharsets.UTF_8); + Files.writeString(configs.resolve("jsonfiles/accounts.json"), "{\"id\":1}\n", StandardCharsets.UTF_8); + String projectChrome = Files.readString(configs.resolve("CHROME.yaml")); + + AgentCoordination.Run first = AgentCoordination.begin(project, request("run-a", "agent-a")); + assertTrue(Files.readString(first.configDirectory().resolve("CHROME.yaml")).contains("marker: project")); + assertTrue(Files.readString(first.configDirectory().resolve("URL.yaml")).contains("project-home")); + assertTrue(Files.readString(first.configDirectory().resolve("EDGE.yaml")).contains("project-edge")); + assertEquals("{\"id\":1}\n", Files.readString(first.configDirectory().resolve("jsonfiles/accounts.json"))); + assertTrue(Files.isRegularFile(first.configDirectory().resolve("CHROME_HEADLESS.yaml"))); + assertFalse(Files.exists(configs.resolve("CHROME_HEADLESS.yaml"))); + assertEquals(projectChrome, Files.readString(configs.resolve("CHROME.yaml"))); + + RunConfigs.write(project, "run-a", "CHROME.yaml", "browser: chrome\nmarker: run-a\n"); + assertTrue(Files.readString(first.configDirectory().resolve("CHROME.yaml")).contains("marker: run-a")); + assertEquals(projectChrome, Files.readString(configs.resolve("CHROME.yaml"))); + assertThrows(IllegalArgumentException.class, () -> + RunConfigs.write(project, "run-a", "../CHROME.yaml", "marker: escaped\n")); + assertEquals(projectChrome, Files.readString(configs.resolve("CHROME.yaml"))); + + AgentCoordination.Run second = AgentCoordination.begin(project, request("run-b", "agent-b")); + assertTrue(Files.readString(second.configDirectory().resolve("CHROME.yaml")).contains("marker: project")); + assertFalse(Files.readString(second.configDirectory().resolve("CHROME.yaml")).contains("run-a")); + assertTrue(Files.readString(first.configDirectory().resolve("CHROME.yaml")).contains("marker: run-a")); + + AgentCoordination.begin(project, request("run-a", "agent-a")); + assertTrue(Files.readString(first.configDirectory().resolve("CHROME.yaml")).contains("marker: run-a")); + ParsingMap.initializeConfigs(configs.toString()); + assertEquals("run-a", String.valueOf(ParsingMap.getGlobalsParsingmap().get("configs.CHROME.marker"))); + assertEquals("project-home", String.valueOf(ParsingMap.getGlobalsParsingmap().get("configs.URL.home"))); + + AgentCoordination.begin(project, request("run-b", "agent-b")); + ParsingMap.initializeConfigs(configs.toString()); + assertEquals("project", String.valueOf(ParsingMap.getGlobalsParsingmap().get("configs.CHROME.marker"))); + RunConfigs.write(project, "run-b", "URL.yaml", "home: run-b-only\n"); + assertTrue(Files.readString(configs.resolve("URL.yaml")).contains("project-home")); + assertTrue(Files.readString(first.configDirectory().resolve("URL.yaml")).contains("project-home")); + assertTrue(Files.readString(second.configDirectory().resolve("URL.yaml")).contains("run-b-only")); + } + + @Test + void aNormalTestDoesNotCopyAndStillReadsTheProjectConfigs() throws Exception { + Path configs = project.resolve("src/test/resources/configs"); + Files.createDirectories(configs); + Files.writeString(configs.resolve("URL.yaml"), "home: from-project\n", StandardCharsets.UTF_8); + Files.createDirectories(project.resolve(".pickleball/runs/stale/config")); + Files.writeString( + project.resolve(".pickleball/runs/stale/config/URL.yaml"), + "home: from-stale-run\n", + StandardCharsets.UTF_8 + ); + + assertNull(AgentCoordination.openConsumerRun(project, new StringBuilder())); + assertFalse(Files.isDirectory(project.resolve(".pickleball/runs/stale/reports"))); + ParsingMap.initializeConfigs(configs.toString()); + assertEquals("from-project", String.valueOf(ParsingMap.getGlobalsParsingmap().get("configs.URL.home"))); + } + + @Test + void openConsumerRunCopiesTheResolvedPathAndNotTheProjectFiles() throws Exception { + Path custom = project.resolve("qa-configs"); + Files.createDirectories(custom); + Files.writeString(custom.resolve("EDGE.yaml"), "browser: edge\nmarker: custom\n", StandardCharsets.UTF_8); + System.setProperty(PKB_props.PKB_RUN_ID, "run-custom"); + System.setProperty(PKB_props.PKB_AGENT_ID, "agent-custom"); + + AgentCoordination.Run run = AgentCoordination.openConsumerRun(project, new StringBuilder(), custom.toString()); + assertTrue(Files.readString(run.configDirectory().resolve("EDGE.yaml")).contains("marker: custom")); + assertEquals("browser: edge\nmarker: custom\n", Files.readString(custom.resolve("EDGE.yaml"))); + ParsingMap.initializeConfigs("configs"); + assertEquals("custom", String.valueOf(ParsingMap.getGlobalsParsingmap().get("configs.EDGE.marker"))); + } + + private static AgentCoordination.Request request(String runId, String agentId) { + return AgentCoordination.Request.of(runId, agentId, null, null, null, null, "config copy"); + } +} From 137383b2243f2b5ec40acbe6535cef50f885c06f Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Mon, 5 Oct 2026 21:00:22 +0000 Subject: [PATCH 20/23] Bottom line: a continuation phrase inherits the Data Table the previous phrase installed. Phrases resolve their text before the line links them, so every phrase cached the step map. After a conditional branch stopped rebuilding at execution time, "for every Data Row" kept that empty step map and the Data Table context was null. At run time, a continuation drops only that premature step-map cache. A map installed for the phrase, including a data-row clone, stays. --- .../treeparsing/parsedComponents/Phrase.java | 1 + .../parsedComponents/PhraseData.java | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java index 51590ec67..7f966c602 100644 --- a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java +++ b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/Phrase.java @@ -107,6 +107,7 @@ boolean shouldRun() { @Override public PhraseData runPhrase() { + inheritParsingMapFromPrevious(); executePhrase(); resolveResults(); setDefaultEntry(getRunningStep().stepEntry); diff --git a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java index b9cf0a79d..c84f9d170 100644 --- a/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java +++ b/src/main/java/tools/dscode/common/treeparsing/parsedComponents/PhraseData.java @@ -147,6 +147,31 @@ public ParsingMap getPhraseParsingMap() { } return phraseParsingMap; } + + /** + * Construction resolves text before phrases are linked, so a continuation + * caches the step map and never sees a Data Table the previous phrase installs. + * Drop only that premature step-map cache. A map installed for this phrase + * (a data-row clone) is left alone. + */ + void inheritParsingMapFromPrevious() { + if (phraseParsingMap == null || isNewContext()) { + return; + } + PhraseData previous = getPreviousPhrase(); + if (previous == null) { + return; + } + Character end = previous.termination; + if (end != null && (end == '.' || end == '?')) { + return; + } + var step = getRunningStep(); + if (step != null && phraseParsingMap == step.getStepParsingMap()) { + phraseParsingMap = null; + } + } + public String resolveText(String inputText) { return getPhraseParsingMap().resolveWholeText(inputText); } From a88d50f5847d3dc8ab1bc6acb57ac21d2621c482 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Mon, 5 Oct 2026 23:54:16 +0000 Subject: [PATCH 21/23] Bottom line: a blocked driver download is a log line, and the launch binary is the matching major rather than a cached neighbor. Local Chrome and Edge can cache a driver when Selenium Manager cannot fetch one. An open session keeps its executable. The last eight majors are a per-browser cache fill, not the driver this session launches. --- AGENTS.md | 1 + docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 3 + docs/ai-run-configuration.md | 2 +- docs/configuration.md | 30 + docs/consumer-agent-guide.md | 2 + docs/getting-started.md | 2 + .../common/driver/DriverConstruction.java | 219 +- .../common/driver/DriverDownloadFallback.java | 1886 +++++++++++++++++ .../tools/dscode/testengine/PKB_props.java | 3 + .../testengine/SensitiveConfiguration.java | 3 +- .../pickleball/guidance/AGENT-GUIDE.md | 2 + .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 3 + .../guidance/docs/ai-run-configuration.md | 2 +- .../pickleball/guidance/docs/configuration.md | 30 + .../guidance/docs/consumer-agent-guide.md | 2 + .../guidance/docs/getting-started.md | 2 + .../driver/DriverDownloadFallbackTest.java | 469 ++++ .../DriverDownloadRedactionTest.java | 32 + 20 files changed, 2672 insertions(+), 25 deletions(-) create mode 100644 src/main/java/tools/dscode/common/driver/DriverDownloadFallback.java create mode 100644 src/test/java/tools/dscode/common/driver/DriverDownloadFallbackTest.java create mode 100644 src/test/java/tools/dscode/testengine/DriverDownloadRedactionTest.java diff --git a/AGENTS.md b/AGENTS.md index cebd24800..8fe82f6fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,7 @@ Treat these as consumer-visible contracts unless source evidence clearly shows o - Mapping, template, key-expression, and reference syntax - Configuration keys, defaults, and resource lookup rules - Selenium element lookup, interaction, retry, and stale-element behavior +- Local Chrome and Edge driver startup. Selenium Manager runs first unless `pkb_driver_download_native=false`. A best-effort fallback may then cache a matching Chrome for Testing or Edge driver. Discovery, download, unzip, quarantine, and certificate failures are logged and do not fail the test. Remote WebDriver is unchanged. An explicit `driver.service.driverExecutable` skips the download. The only configuration that fails closed is native and fallback both disabled with no `driverExecutable`. - Service-call definitions, request/response mapping, and REST/SOAP behavior - Component-scenario, nested-step, and conditional-flow semantics - Cucumber compatibility and AspectJ weaving behavior diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 567b211cf..a22181563 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -15,7 +15,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Direct service-call control/evidence | `ServiceCallControl.java`; `ServiceCallEvidence.java`; `BoundedJsonEvidence.java`; `ServiceCallSteps.java`; `ModularScenarios.java`; `RestAssuredUtil.java`; bridge `/v1/services/call`; Workbench `workbench_service_call` | `@control-bridge`; existing service-call features | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/service-call-scenarios.md` | | Semantic breakpoints | `ControlBridgeCoordinator.java`; `ControlBridgeBreakpoint.java`; `ControlHook.java`; bridge `/v1/breakpoints*`; Workbench `workbench_breakpoint_*` | `@control-bridge`; Workbench UI/MCP tests and `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md` | | Dynamic steps/expression execution | `DynamicSteps.java`; `DynamicExecution.java`; `StepExtension.java`; tree-parsing classes; `PlaceHolderMatch`; `DefinitionContext.itPlaceholder` | `dynamic-steps.feature`; `forms-dynamic-steps.feature`; `it-placeholder.feature`; `PlaceHolderMatchChecks.java` | `docs/dynamic-steps.md` | -| Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | +| Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; `DriverDownloadFallback.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; `DriverDownloadFallbackTest.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | | Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `RunConfigCopy`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index f6f0bcf54..29f585d5c 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -210,6 +210,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/domoperations/SeleniumUtils.java` - `src/main/java/tools/dscode/common/domoperations/TableColumnByHeaderXPath.java` - `src/main/java/tools/dscode/common/driver/DriverConstruction.java` +- `src/main/java/tools/dscode/common/driver/DriverDownloadFallback.java` - `src/main/java/tools/dscode/common/evaluations/AviatorFunctions.java` - `src/main/java/tools/dscode/common/evaluations/AviatorUtil.java` - `src/main/java/tools/dscode/common/evaluations/BookendUtils.java` @@ -411,6 +412,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` - `src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java` +- `src/test/java/tools/dscode/common/driver/DriverDownloadFallbackTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` @@ -428,6 +430,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java` - `src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorTest.java` - `src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java` +- `src/test/java/tools/dscode/testengine/DriverDownloadRedactionTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` - `src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java` diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index 21eb0454c..407bd0f61 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -430,7 +430,7 @@ When operating in a consumer project: ## AI agents -The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. A blocked local driver download does not fail the run: Selenium Manager is tried first unless `pkb_driver_download_native=false`, and the fallback is skipped only when `pkb_driver_download_proxy=false`. An explicit `driver.service.driverExecutable` skips both. The proxy value is redacted. Do not invent a `configs` proxy block and do not copy it onto `HTTPS_PROXY` or `SE_PROXY`. See [Execution Configuration](configuration.md). Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. diff --git a/docs/configuration.md b/docs/configuration.md index 905a70c06..8807a1294 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -391,6 +391,9 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_example` | `1 2 5 3.4 7-11` | Examples-row filter applied after tags and name. Not a tag | | `pkb_environment` | `QA` | project environment label | | `pkb_browser` | `chrome` | browser configuration name looked up under the `configs` mapping (`CHROME_HEADLESS` uses the consumer yaml when present, otherwise Pickleball's bundled headless Chrome) | +| `pkb_driver_download_native` | `true`, `false` | `true` or unset tries Selenium Manager first; `false` keeps that launch off the network | +| `pkb_driver_download_proxy` | empty, `false`, or `http://user:pass@host:port` | empty discovers a driver-download proxy; `false` disables the fallback; a URL is tried first and is redacted | +| `pkb_driver_download_ca` | PEM file path | optional corporate CA for the driver downloader only | | `pkb_profile` | `qa,browser_firefox` | selected named profile(s) | | `pkb_runvars` | `pkb_tags=@smoke, pkb_browser=chrome` | compact controlled RunVar input | | `pkb_runvars.` | `pkb_runvars.pkb_browser=chrome` | expanded controlled RunVar member | @@ -450,6 +453,33 @@ The resolved integer is stamped into the final RunVars and `pkb_run_profile`. Wo The bundled headless config uses `--headless=new`, a fixed `--window-size=1920,1080`, no `MAXIMIZE`, and `QUIT_LOCAL_DRIVER`. Consumer `CHROME`, `EDGE`, `GRID`, and `SAUCE` yaml files are unchanged. Agents can set `pkb_browser=CHROME_HEADLESS` without copying yaml into the project. +## Local driver download + +Local Chrome and Edge start through Selenium. Remote WebDriver is unchanged. Bundled and consumer `CHROME_HEADLESS` configs set `driver.service.port` and `driver.service.verbose` and do not set `driver.service.driverExecutable`. Selenium Manager then resolves the driver. Behind a corporate proxy that child often cannot log in, trust a re-signed certificate, or reach `storage.googleapis.com`. + +Pickleball keeps a separate best-effort download for that case. It does not add a proxy block under `configs`, and it does not copy the download proxy onto `http.proxyHost`, `https.proxyHost`, `HTTPS_PROXY`, or `SE_PROXY`. Browser `--proxy-server` and `pkb_rp_http_proxy_*` stay on their own channels. + +| Property | Values | Meaning | +|---|---|---| +| `pkb_driver_download_native` | `true` or unset | Try Selenium Manager first | +| `pkb_driver_download_native` | `false` | Do not let Selenium Manager use the network for this launch. Use a cached or fallback executable. `SE_OFFLINE` is set only around that launch | +| `pkb_driver_download_proxy` | empty or unset | Discover a proxy, then try a direct connection | +| `pkb_driver_download_proxy` | `false` | Do not run the fallback. `false` is not a hostname | +| `pkb_driver_download_proxy` | one `http://user:pass@host:port` line | Try this proxy first. Percent-encode a password that contains `@` or `:` | +| `pkb_driver_download_ca` | optional PEM file | Corporate CA used only by the downloader. It is not installed into the JVM trust store | + +`pkb_driver_download_proxy` is redacted everywhere a run profile, diagnostic, or log can show it, the same way `pkb_rp_http_proxy_password` is redacted. + +Native then fallback is the default. Fallback only, native only, and neither are valid. Neither requires `driver.service.driverExecutable`. If both are disabled and no executable is set, startup fails and the message names those two properties. An executable that is already set skips the download. + +Discovery runs once per JVM. The order is the `pkb_driver_download_proxy` value, then `HTTPS_PROXY` and `SE_PROXY`, then the OS system proxy, then the lower-level OS setting, then a direct connection. A connection, login, or certificate failure tries the next proxy. HTTP 403 or a block page after a successful handshake keeps that proxy and tries the next hostname. Direct is the last proxy configuration, not another hostname. Windows may use WinHTTP or a short PowerShell `-UseDefaultCredentials` helper for current-user proxy login. macOS reads `scutil --proxy`. Linux uses the environment, and a GNOME `gsettings` read is optional. A missing tool is skipped. + +Chrome metadata comes from `googlechromelabs.github.io/chrome-for-testing` (`LATEST_RELEASE_STABLE`, `LATEST_RELEASE_`, the known-good and milestone JSON files). Chrome zips are tried as `commondatastorage.googleapis.com`, then `storage.googleapis.com`, then `edgedl.me.gvt1.com`. Edge metadata is UTF-16 LE. The version URL is `https://msedgedriver.microsoft.com/LATEST_RELEASE__WINDOWS` (or `_LINUX` / `_MACOS`), and the zip is `edgedriver_win64.zip`, `edgedriver_mac64.zip`, `edgedriver_mac64_m1.zip`, or `edgedriver_linux64.zip`. Pickleball does not use `chromedriver.storage.googleapis.com`, `msedgedriver.azureedge.net`, or `msedgewebdriverstorage.blob.core.windows.net`. + +The installed browser major wins: Windows file version or registry, and `--version` elsewhere. A Selenium Manager failure that already names a versioned zip is retried on the other hosts. `SessionNotCreatedException` text `Current browser version is ...` is parsed and retried once for that major. If the session starts and the driver only logs that it has not been tested with this browser, the test keeps that driver and caches the matching major for the next launch. It does not replace the open executable. With no version at all, only the current Stable driver is used, and only when no driver is already running. The last 8 stable majors are a cache fill for that browser, counted separately for Chrome and Edge. A major already in the cache is skipped. The lock is per browser, platform, and version. The launch never points `driverExecutable` at one of the other seven. + +Files go in Selenium Manager's cache: the `SE_CACHE_PATH` system property, otherwise the `SE_CACHE_PATH` environment variable, otherwise `cache-path` in `~/.cache/selenium/se-config.toml`, otherwise `~/.cache/selenium`, under `chromedriver///` or `msedgedriver///`. The launch also sets `driverExecutable` to the unzipped binary. Each transfer is capped at 20 seconds. A blocked transfer, unzip error, quarantine flag, antivirus lock, or certificate failure is a log line. It does not fail the test. + ## Agent Discover browser ladder Workbench Discover/Confirm do not blindly MUST-use `CHROME_HEADLESS` for every project: diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 33510ba41..5bc668513 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -176,6 +176,8 @@ pkb_scenarioReport=false Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. +Local Chrome and Edge do not need a `driverExecutable`. Selenium Manager runs first (`pkb_driver_download_native=false` stops that network use for the launch). If the manager cannot fetch a driver, a best-effort download may cache one. Discovery, a 403, a certificate failure, or an antivirus lock is a log line, not a test error. Do not treat that log as a product failure, and do not add a proxy example under `configs`. `pkb_driver_download_proxy=false` turns the fallback off. A URL in that property is redacted in `pkb_run_profile` and diagnostics. Remote browsers are unchanged. + These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: ```text diff --git a/docs/getting-started.md b/docs/getting-started.md index 9f5f94355..f9a79d3e2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -123,6 +123,8 @@ qa: pkb_browser: CHROME_HEADLESS ``` +`pkb_browser` selects a yaml config. Local Chrome and Edge still start through Selenium. When that config has no `driver.service.driverExecutable`, Selenium Manager runs first. If it cannot download a driver, Pickleball tries a best-effort Chrome for Testing or Edge driver and caches it. A blocked download is a log line, not a test failure. Set `pkb_driver_download_proxy=false` to skip that fallback, or `pkb_driver_download_native=false` to keep Selenium Manager off the network for the launch. Do not put the download proxy in the browser yaml. See [Execution Configuration](configuration.md). + ```bash ./mvnw test -Dpkb_profile=qa ``` diff --git a/src/main/java/tools/dscode/common/driver/DriverConstruction.java b/src/main/java/tools/dscode/common/driver/DriverConstruction.java index 803bb6971..858e6b355 100644 --- a/src/main/java/tools/dscode/common/driver/DriverConstruction.java +++ b/src/main/java/tools/dscode/common/driver/DriverConstruction.java @@ -19,10 +19,12 @@ import tools.dscode.common.coordination.AgentCoordination; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.File; import java.io.OutputStream; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -39,6 +41,8 @@ public final class DriverConstruction { + private static final Object SELENIUM_MANAGER_LAUNCH = new Object(); + private DriverConstruction() { } @@ -127,28 +131,155 @@ public static RemoteWebDriver createLocalDriver( ClientConfig clientConfig = buildClientConfig(getObject(driverConfig, "connection")); return switch (browserName) { - case "chrome" -> { - ChromeOptions options = buildChromeOptions(driverConfig, fullConfiguration, true); - ChromeDriverService service = buildChromeService(toObjectMap(getObject(driverConfig, "service"))); - - yield service != null && clientConfig != null ? new ChromeDriver(service, options, clientConfig) - : service != null ? new ChromeDriver(service, options) - : clientConfig != null ? new ChromeDriver(options, clientConfig) - : new ChromeDriver(options); - } - case "edge" -> { - EdgeOptions options = buildEdgeOptions(driverConfig, fullConfiguration, true); - EdgeDriverService service = buildEdgeService(toObjectMap(getObject(driverConfig, "service"))); - - yield service != null && clientConfig != null ? new EdgeDriver(service, options, clientConfig) - : service != null ? new EdgeDriver(service, options) - : clientConfig != null ? new EdgeDriver(options, clientConfig) - : new EdgeDriver(options); - } + case "chrome", "edge" -> launchLocalChromium(browserName, driverConfig, fullConfiguration, clientConfig); default -> throw new RuntimeException("Unsupported local browser: " + browserName); }; } + private static RemoteWebDriver launchLocalChromium( + String browserName, + ObjectNode driverConfig, + ObjectNode fullConfiguration, + ClientConfig clientConfig + ) throws Exception { + Map service = new LinkedHashMap<>(toObjectMap(getObject(driverConfig, "service"))); + String preset = trimToNull(service.get("driverExecutable")); + String binary = trimToNull(getObject(driverConfig, "options").path("binary").asText(null)); + if (preset == null) { + DriverDownloadFallback.prepareLaunch(browserName, service, binary); + } + ByteArrayOutputStream capture = new ByteArrayOutputStream(); + try { + RemoteWebDriver driver = startChromium(browserName, driverConfig, fullConfiguration, clientConfig, service, capture); + if (preset == null) { + DriverDownloadFallback.keepOpenDriver( + trimToNull(service.get("driverExecutable")), browserName, driverLog(service, capture)); + } + return driver; + } catch (RuntimeException first) { + if (preset != null) { + throw first; + } + if (trimToNull(service.get("driverExecutable")) != null && executableSetupFailed(first)) { + logInfo("Could not set driverExecutable; continuing without it"); + service.remove("driverExecutable"); + try { + RemoteWebDriver driver = startChromium(browserName, driverConfig, fullConfiguration, clientConfig, service, capture); + DriverDownloadFallback.keepOpenDriver( + trimToNull(service.get("driverExecutable")), browserName, driverLog(service, capture)); + return driver; + } catch (RuntimeException ignored) { + first = ignored; + } + } + boolean sessionRetried = DriverDownloadFallback.isSessionVersionMismatch(first); + String recovered = DriverDownloadFallback.recoverExecutable(browserName, first, binary, false); + String current = trimToNull(service.get("driverExecutable")); + if (recovered == null || recovered.equals(current)) { + throw first; + } + service.put("driverExecutable", recovered); + capture.reset(); + try { + RemoteWebDriver driver = startChromium(browserName, driverConfig, fullConfiguration, clientConfig, service, capture); + DriverDownloadFallback.keepOpenDriver( + trimToNull(service.get("driverExecutable")), browserName, driverLog(service, capture)); + return driver; + } catch (RuntimeException second) { + if (!DriverDownloadFallback.allowsAnotherSessionRetry(sessionRetried, second)) { + throw second; + } + String again = DriverDownloadFallback.recoverExecutable(browserName, second, binary, false); + if (again == null || again.equals(recovered)) { + throw second; + } + service.put("driverExecutable", again); + capture.reset(); + RemoteWebDriver driver = startChromium(browserName, driverConfig, fullConfiguration, clientConfig, service, capture); + DriverDownloadFallback.keepOpenDriver( + trimToNull(service.get("driverExecutable")), browserName, driverLog(service, capture)); + return driver; + } + } + } + + private static String driverLog(Map service, ByteArrayOutputStream capture) { + String captured = capture.toString(StandardCharsets.UTF_8); + if (!captured.isBlank()) { + return captured; + } + String logFile = trimToNull(service.get("logFile")); + if (logFile == null) { + return captured; + } + try { + return Files.readString(Path.of(logFile)); + } catch (IOException failure) { + return captured; + } + } + + private static boolean executableSetupFailed(RuntimeException failure) { + String message = failure.getMessage() == null ? "" : failure.getMessage().toLowerCase(Locale.ROOT); + return message.contains("executable") || message.contains("not a file") || message.contains("unable to find"); + } + + private static RemoteWebDriver startChromium( + String browserName, + ObjectNode driverConfig, + ObjectNode fullConfiguration, + ClientConfig clientConfig, + Map service, + ByteArrayOutputStream capture + ) { + boolean callsManager = trimToNull(service.get("driverExecutable")) == null; + if (!callsManager) { + return newChromiumDriver(browserName, driverConfig, fullConfiguration, clientConfig, service, capture); + } + synchronized (SELENIUM_MANAGER_LAUNCH) { + boolean offline = !DriverDownloadFallback.nativeDownloadsEnabled(); + String previous = System.getProperty("SE_OFFLINE"); + try { + if (offline) { + System.setProperty("SE_OFFLINE", "true"); + } + return newChromiumDriver(browserName, driverConfig, fullConfiguration, clientConfig, service, capture); + } finally { + if (offline) { + if (previous == null) { + System.clearProperty("SE_OFFLINE"); + } else { + System.setProperty("SE_OFFLINE", previous); + } + } + } + } + } + + private static RemoteWebDriver newChromiumDriver( + String browserName, + ObjectNode driverConfig, + ObjectNode fullConfiguration, + ClientConfig clientConfig, + Map service, + ByteArrayOutputStream capture + ) { + if ("edge".equals(browserName)) { + EdgeOptions options = buildEdgeOptions(driverConfig, fullConfiguration, true); + EdgeDriverService built = buildEdgeService(service, capture); + return built != null && clientConfig != null ? new EdgeDriver(built, options, clientConfig) + : built != null ? new EdgeDriver(built, options) + : clientConfig != null ? new EdgeDriver(options, clientConfig) + : new EdgeDriver(options); + } + ChromeOptions options = buildChromeOptions(driverConfig, fullConfiguration, true); + ChromeDriverService built = buildChromeService(service, capture); + return built != null && clientConfig != null ? new ChromeDriver(built, options, clientConfig) + : built != null ? new ChromeDriver(built, options) + : clientConfig != null ? new ChromeDriver(options, clientConfig) + : new ChromeDriver(options); + } + public static RemoteWebDriver createRemoteDriver( String browserName, ObjectNode driverConfig, @@ -312,6 +443,10 @@ public static void applyCapabilitiesExcept( } public static ChromeDriverService buildChromeService(Map serviceMap) { + return buildChromeService(serviceMap, null); + } + + static ChromeDriverService buildChromeService(Map serviceMap, OutputStream capture) { Map service = safeMap(serviceMap); if (service.isEmpty()) { return null; @@ -321,7 +456,6 @@ public static ChromeDriverService buildChromeService(Map service ChromeDriverService.Builder builder = new ChromeDriverService.Builder(); applyCommonServiceSettings(builder, service); - if (service.containsKey("appendLog")) { builder.withAppendLog(booleanOrDefault(service.get("appendLog"), false)); } @@ -349,11 +483,16 @@ public static ChromeDriverService buildChromeService(Map service builder.withLogLevel(logLevel); } } + attachLogCapture(builder, service, capture); return builder.build(); } public static EdgeDriverService buildEdgeService(Map serviceMap) { + return buildEdgeService(serviceMap, null); + } + + static EdgeDriverService buildEdgeService(Map serviceMap, OutputStream capture) { Map service = safeMap(serviceMap); if (service.isEmpty()) { return null; @@ -363,7 +502,6 @@ public static EdgeDriverService buildEdgeService(Map serviceMap) EdgeDriverService.Builder builder = new EdgeDriverService.Builder(); applyCommonServiceSettings(builder, service); - if (service.containsKey("appendLog")) { builder.withAppendLog(booleanOrDefault(service.get("appendLog"), false)); } @@ -391,10 +529,51 @@ public static EdgeDriverService buildEdgeService(Map serviceMap) builder.withLoglevel(logLevel); } } + attachLogCapture(builder, service, capture); return builder.build(); } + private static > void attachLogCapture( + B builder, + Map service, + OutputStream capture + ) { + if (capture == null || trimToNull(service.get("logFile")) != null) { + return; + } + OutputStream configured = resolveLogOutput(service.get("logOutput")); + builder.withLogOutput(configured == null ? capture : new TeeOutput(configured, capture)); + } + + private static final class TeeOutput extends OutputStream { + private final OutputStream left; + private final OutputStream right; + + private TeeOutput(OutputStream left, OutputStream right) { + this.left = left; + this.right = right; + } + + @Override + public void write(int value) throws IOException { + left.write(value); + right.write(value); + } + + @Override + public void write(byte[] buffer, int offset, int length) throws IOException { + left.write(buffer, offset, length); + right.write(buffer, offset, length); + } + + @Override + public void flush() throws IOException { + left.flush(); + right.flush(); + } + } + public static > void applyCommonServiceSettings( B builder, Map service diff --git a/src/main/java/tools/dscode/common/driver/DriverDownloadFallback.java b/src/main/java/tools/dscode/common/driver/DriverDownloadFallback.java new file mode 100644 index 000000000..d81a5fbb1 --- /dev/null +++ b/src/main/java/tools/dscode/common/driver/DriverDownloadFallback.java @@ -0,0 +1,1886 @@ +package tools.dscode.common.driver; + +import com.fasterxml.jackson.databind.JsonNode; + +import tools.dscode.testengine.PKB_props; +import tools.dscode.testengine.PickleballRunner; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static tools.dscode.common.mappings.ValueFormatting.MAPPER; +import static tools.dscode.common.reporting.logging.LogForwarder.logWarn; + +/** + * Best-effort Chrome for Testing and Microsoft Edge driver download. + * Every failure here is a log line. The only thrown configuration error is an explicit + * native-and-fallback disable with no {@code driverExecutable}. + */ +public final class DriverDownloadFallback { + + public static final String DIRECT = "direct"; + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration TRANSFER_CAP = Duration.ofSeconds(20); + private static final int MAX_BYTES = 80 * 1024 * 1024; + private static final int RECENT_MAJORS = 8; + private static final String CHROME_METADATA = "https://googlechromelabs.github.io/chrome-for-testing/"; + private static final String EDGE_METADATA = "https://msedgedriver.microsoft.com/"; + private static final Pattern VERSION = Pattern.compile("(\\d+\\.\\d+\\.\\d+\\.\\d+)"); + private static final Pattern SESSION_BROWSER = Pattern.compile( + "Current browser version is\\s+(\\d+(?:\\.\\d+)*)", Pattern.CASE_INSENSITIVE); + private static final Pattern DRIVER_URL_VERSION = Pattern.compile( + "(?:chrome-for-testing-public|chrome/chrome-for-testing|msedgedriver\\.microsoft\\.com)/(\\d+\\.\\d+\\.\\d+\\.\\d+)"); + private static final Pattern UNTESTED = Pattern.compile( + "has not been tested with.{0,40}?version\\s+(\\d+)", Pattern.CASE_INSENSITIVE); + + private static final Object DISCOVERY = new Object(); + private static final AtomicReference CHOSEN = new AtomicReference<>(); + private static volatile Transfer transferOverride; + private static volatile VersionLookup versionOverride; + private static volatile ExecutorService fillExecutor; + private static volatile boolean synchronousFill; + + @FunctionalInterface + interface Transfer { + byte[] get(List urls) throws IOException; + } + + @FunctionalInterface + interface VersionLookup { + String version(String browser, String binary); + } + + public enum ProxyMode { + DISCOVER, + DISABLED, + EXPLICIT + } + + public record ProxyCandidate(String source, String value) { + } + + private DriverDownloadFallback() { + } + + public static ProxyMode proxyMode(String raw) { + if (raw == null || raw.isBlank()) { + return ProxyMode.DISCOVER; + } + if ("false".equalsIgnoreCase(raw.trim())) { + return ProxyMode.DISABLED; + } + return ProxyMode.EXPLICIT; + } + + public static boolean nativeEnabled(String raw) { + return raw == null || raw.isBlank() || !"false".equalsIgnoreCase(raw.trim()); + } + + public static boolean nativeDownloadsEnabled() { + return nativeEnabled(property(PKB_props.PKB_DRIVER_DOWNLOAD_NATIVE)); + } + + public static String browserVersionFromSessionMessage(String text) { + if (text == null || text.isBlank()) { + return null; + } + Matcher matcher = SESSION_BROWSER.matcher(text); + return matcher.find() ? matcher.group(1) : null; + } + + public static String browserVersionFromSessionMessage(Throwable failure) { + return browserVersionFromSessionMessage(messageChain(failure)); + } + + public static boolean isSessionVersionMismatch(Throwable failure) { + return browserVersionFromSessionMessage(failure) != null; + } + + public static String versionFromDriverDownloadUrl(String text) { + if (text == null || text.isBlank()) { + return null; + } + Matcher matcher = DRIVER_URL_VERSION.matcher(text); + return matcher.find() ? matcher.group(1) : null; + } + + public static String versionFromFailure(Throwable failure) { + String text = messageChain(failure); + String session = browserVersionFromSessionMessage(text); + return session != null ? session : versionFromDriverDownloadUrl(text); + } + + public static String decodeEdgeVersionText(byte[] body) { + if (body == null || body.length == 0) { + return ""; + } + String decoded; + if (body.length >= 2 && (body[0] & 0xFF) == 0xFF && (body[1] & 0xFF) == 0xFE) { + decoded = new String(body, 2, body.length - 2, StandardCharsets.UTF_16LE); + } else if (body.length >= 2 && (body[0] & 0xFF) == 0xFE && (body[1] & 0xFF) == 0xFF) { + decoded = new String(body, 2, body.length - 2, StandardCharsets.UTF_16BE); + } else if (looksLikeUtf16Le(body)) { + decoded = new String(body, StandardCharsets.UTF_16LE); + } else { + decoded = new String(body, StandardCharsets.US_ASCII); + } + return decoded.replace("\uFEFF", "").replace("\r", "").replace("\n", "").trim(); + } + + public static List chromeZipUrls(String version, String platform) { + String ver = version.trim(); + String plat = platform.trim(); + String name = "chromedriver-" + plat + ".zip"; + return List.of( + "https://commondatastorage.googleapis.com/chrome-for-testing-public/" + ver + "/" + plat + "/" + name, + "https://storage.googleapis.com/chrome-for-testing-public/" + ver + "/" + plat + "/" + name, + "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/" + ver + "/" + plat + "/" + name + ); + } + + public static List edgeZipUrls(String version, String zipLabel) { + return List.of(EDGE_METADATA + version.trim() + "/edgedriver_" + zipLabel.trim() + ".zip"); + } + + public static String edgeMajorMetadataUrl(String major, String osToken) { + return EDGE_METADATA + "LATEST_RELEASE_" + major + "_" + osToken; + } + + public static List proxyCandidates( + String pkbValue, + Map environment, + List osSystem, + List osLower + ) { + if (proxyMode(pkbValue) == ProxyMode.DISABLED) { + return List.of(); + } + List candidates = new ArrayList<>(); + if (proxyMode(pkbValue) == ProxyMode.EXPLICIT) { + candidates.add(new ProxyCandidate(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY, pkbValue.trim())); + } + Map env = environment == null ? Map.of() : environment; + for (String key : List.of("HTTPS_PROXY", "https_proxy", "SE_PROXY", "se_proxy")) { + String value = env.get(key); + if (value != null && !value.isBlank()) { + candidates.add(new ProxyCandidate(key, value.trim())); + } + } + addAll(candidates, "os-proxy", osSystem); + addAll(candidates, "os-lower", osLower); + candidates.add(new ProxyCandidate(DIRECT, DIRECT)); + return dedupe(candidates); + } + + public static Path resolveCacheDirectory( + Map environment, + String systemProperty, + Path homeDirectory, + String seConfigToml + ) { + String configured = firstNonBlank(systemProperty, environment == null ? null : environment.get("SE_CACHE_PATH")); + if (configured == null && seConfigToml != null) { + configured = cachePathFromToml(seConfigToml); + } + if (configured == null) { + return homeDirectory.resolve(".cache").resolve("selenium"); + } + String expanded = configured.startsWith("~") + ? homeDirectory + configured.substring(1) + : configured; + return Path.of(expanded); + } + + /** + * Resolve a cached or downloaded executable before launch when Selenium Manager must not + * touch the network. Never downloads when an executable is already set. + */ + public static void prepareLaunch(String browser, Map service, String browserBinary) { + if (service == null || text(service.get("driverExecutable")) != null) { + return; + } + ProxyMode mode = proxyMode(property(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY)); + boolean nativeOn = nativeDownloadsEnabled(); + if (!nativeOn && mode == ProxyMode.DISABLED) { + throw new IllegalStateException(bothDisabledMessage(browser)); + } + if (nativeOn || mode == ProxyMode.DISABLED) { + return; + } + try { + String executable = executableFor(browser, installedBrowserVersion(browser, browserBinary), true); + if (executable != null) { + service.put("driverExecutable", executable); + } + } catch (Throwable failure) { + logFallback("Driver download fallback did not run before launch: " + safe(failure)); + } + } + + /** + * After a failed launch, try once to place a matching major on disk. Returns null instead of throwing. + * A session that already started is left alone. + */ + public static String recoverExecutable(String browser, Throwable failure, String browserBinary, boolean sessionStarted) { + if (sessionStarted || proxyMode(property(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY)) == ProxyMode.DISABLED) { + return null; + } + try { + String version = versionFromFailure(failure); + boolean fromBrowser = false; + if (version == null) { + version = installedBrowserVersion(browser, browserBinary); + fromBrowser = version != null; + } + return executableFor(browser, version, !fromBrowser && versionFromFailure(failure) == null); + } catch (Throwable error) { + logFallback("Driver download recovery failed: " + safe(error)); + return null; + } + } + + /** Cache a closer major for the next launch. Does not replace a driver that already started. */ + public static void noteUntestedBrowser(String browser, String driverLog) { + try { + if (driverLog == null || proxyMode(property(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY)) == ProxyMode.DISABLED) { + return; + } + Matcher matcher = UNTESTED.matcher(driverLog); + if (!matcher.find()) { + return; + } + String major = matcher.group(1); + enqueueFill(() -> { + try { + executableFor(browser, major, false); + } catch (Throwable failure) { + logFallback("Could not cache the untested " + browser + " major: " + safe(failure)); + } + }); + } catch (Throwable failure) { + logFallback("Could not read the driver version warning: " + safe(failure)); + } + } + + /** + * The open session keeps {@code currentExecutable}. A "not tested with this browser" warning + * only fills the cache for the next launch. + */ + public static String keepOpenDriver(String currentExecutable, String browser, String driverLog) { + noteUntestedBrowser(browser, driverLog); + return currentExecutable; + } + + /** A session-version mismatch is retried once. A second mismatch is not another retry. */ + public static boolean allowsAnotherSessionRetry(boolean sessionAlreadyRetried, Throwable failure) { + return !sessionAlreadyRetried && isSessionVersionMismatch(failure); + } + + public static String bothDisabledMessage(String browser) { + return "Local " + browser + " launch has no driver.service.driverExecutable, " + + PKB_props.PKB_DRIVER_DOWNLOAD_NATIVE + "=false, and " + + PKB_props.PKB_DRIVER_DOWNLOAD_PROXY + "=false. " + + "Selenium Manager will not contact the network, and the driver download fallback is disabled."; + } + + static void resetForTests() { + transferOverride = null; + versionOverride = null; + synchronousFill = false; + CHOSEN.set(null); + ExecutorService existing = fillExecutor; + fillExecutor = null; + if (existing != null) { + existing.shutdownNow(); + } + } + + static void useTransferForTests(Transfer transfer) { + transferOverride = transfer; + } + + static void useVersionForTests(VersionLookup lookup) { + versionOverride = lookup; + } + + static void useSynchronousFillForTests() { + synchronousFill = true; + } + + static Path driverCacheFile(String browser, String version) { + String name = "edge".equals(browser) ? "msedgedriver" : "chromedriver"; + return cacheDirectory().resolve(driverFolder(browser)).resolve(cachePlatform(browser)).resolve(version).resolve(name); + } + + /** Missing scutil, netsh, gsettings, or powershell returns null instead of failing the launch. */ + static String toolOutput(String... command) { + return commandOutput(Duration.ofSeconds(3), command); + } + + /** OS proxy discovery is optional. A missing tool is an empty contribution, not an error. */ + static List optionalOsProxies() { + List found = new ArrayList<>(); + try { + found.addAll(osSystemProxies()); + } catch (Throwable failure) { + logFallback("OS proxy discovery skipped: " + safe(failure)); + } + try { + found.addAll(osLowerProxies()); + } catch (Throwable failure) { + logFallback("Lower-level OS proxy discovery skipped: " + safe(failure)); + } + return found; + } + + private static String executableFor(String browser, String version, boolean allowStableWhenUnknown) { + try { + if (version == null || version.isBlank()) { + return allowStableWhenUnknown ? stableExecutable(browser) : null; + } + String resolved = version.trim(); + if (majorOnly(resolved)) { + Path cachedMajor = highestCached(browser, resolved); + if (cachedMajor != null) { + return cachedMajor.toString(); + } + String latest = resolveMajorVersion(browser, resolved); + if (latest == null) { + return null; + } + resolved = latest; + } + Path exact = exactCached(browser, resolved); + if (exact != null) { + return exact.toString(); + } + Path sameMajor = highestCached(browser, majorOf(resolved)); + try { + String installed = install(browser, resolved); + if (installed != null) { + return installed; + } + } catch (Throwable failure) { + logFallback("Driver zip for " + browser + " " + resolved + " was not installed: " + safe(failure)); + } + return sameMajor == null ? null : sameMajor.toString(); + } catch (Throwable failure) { + logFallback("Driver download failed: " + safe(failure)); + return null; + } + } + + private static String stableExecutable(String browser) { + String stable = resolveStableVersion(browser); + if (stable == null) { + return null; + } + String executable = executableFor(browser, stable, false); + enqueueFill(() -> fillRecentMajors(browser, stable)); + return executable; + } + + /** + * Background cache fill for one browser. Chrome and Edge each get their own eight majors. + * Nothing in here is the executable for the session that asked for Stable. + */ + private static void fillRecentMajors(String browser, String stableVersion) { + try { + for (String version : recentMajors(browser, stableVersion)) { + if (exactCached(browser, version) != null || highestCached(browser, majorOf(version)) != null) { + continue; + } + executableFor(browser, version, false); + } + } catch (Throwable failure) { + logFallback("Driver cache fill failed: " + safe(failure)); + } + } + + private static void enqueueFill(Runnable runnable) { + if (synchronousFill) { + try { + runnable.run(); + } catch (Throwable failure) { + logFallback("Driver cache fill failed: " + safe(failure)); + } + return; + } + filler().execute(runnable); + } + + private static List recentMajors(String browser, String stableVersion) { + if ("chrome".equals(browser)) { + byte[] milestones = getQuiet(List.of(CHROME_METADATA + "latest-versions-per-milestone-with-downloads.json")); + List parsed = milestones == null ? List.of() : milestoneVersions(milestones); + if (!parsed.isEmpty()) { + return parsed.size() > RECENT_MAJORS ? parsed.subList(0, RECENT_MAJORS) : parsed; + } + } + String majorText = majorOf(stableVersion); + if (majorText == null) { + return List.of(); + } + int major = Integer.parseInt(majorText); + List versions = new ArrayList<>(); + for (int i = 0; i < RECENT_MAJORS && major - i > 0; i++) { + String resolved = resolveMajorVersion(browser, Integer.toString(major - i)); + if (resolved != null) { + versions.add(resolved); + } + } + return versions; + } + + private static List milestoneVersions(byte[] json) { + try { + JsonNode milestones = MAPPER.readTree(json).path("milestones"); + List versions = new ArrayList<>(); + if (milestones.isArray()) { + for (JsonNode node : milestones) { + addVersion(versions, node.path("version").asText(null)); + } + } else if (milestones.isObject()) { + milestones.fields().forEachRemaining(entry -> { + JsonNode node = entry.getValue(); + addVersion(versions, node.isTextual() ? node.asText() : node.path("version").asText(null)); + }); + } + versions.sort(DriverDownloadFallback::compareVersions); + java.util.Collections.reverse(versions); + return versions; + } catch (Exception failure) { + logFallback("Could not read Chrome milestone metadata: " + safe(failure)); + return List.of(); + } + } + + private static String resolveStableVersion(String browser) { + if ("edge".equals(browser)) { + return firstVersion(getQuiet(List.of(EDGE_METADATA + "LATEST_STABLE"))); + } + String stable = firstVersion(getQuiet(List.of(CHROME_METADATA + "LATEST_RELEASE_STABLE"))); + if (stable != null) { + return stable; + } + byte[] lastKnown = getQuiet(List.of(CHROME_METADATA + "last-known-good-versions-with-downloads.json")); + stable = stableChannelVersion(lastKnown); + if (stable != null) { + return stable; + } + byte[] known = getQuiet(List.of(CHROME_METADATA + "known-good-versions-with-downloads.json")); + return newestKnownVersion(known); + } + + private static String resolveMajorVersion(String browser, String major) { + if (major == null) { + return null; + } + if ("edge".equals(browser)) { + return firstVersion(getQuiet(List.of(edgeMajorMetadataUrl(major, edgeOsToken())))); + } + String released = firstVersion(getQuiet(List.of(CHROME_METADATA + "LATEST_RELEASE_" + major))); + if (released != null) { + return released; + } + byte[] milestones = getQuiet(List.of(CHROME_METADATA + "latest-versions-per-milestone-with-downloads.json")); + for (String version : milestoneVersions(milestones == null ? new byte[0] : milestones)) { + if (major.equals(majorOf(version))) { + return version; + } + } + byte[] perVersion = getQuiet(List.of(CHROME_METADATA + major + ".json")); + return firstVersion(perVersion); + } + + private static String stableChannelVersion(byte[] json) { + if (json == null || json.length == 0) { + return null; + } + try { + JsonNode channels = MAPPER.readTree(json).path("channels"); + for (String name : List.of("Stable", "stable")) { + String version = channels.path(name).path("version").asText(null); + if (version != null && !version.isBlank()) { + return version.trim(); + } + } + } catch (Exception failure) { + logFallback("Could not read Chrome stable metadata: " + safe(failure)); + } + return null; + } + + private static String newestKnownVersion(byte[] json) { + if (json == null || json.length == 0) { + return null; + } + try { + JsonNode versions = MAPPER.readTree(json).path("versions"); + String newest = null; + if (versions.isArray()) { + for (JsonNode node : versions) { + String version = node.path("version").asText(null); + if (version != null && (newest == null || compareVersions(version, newest) > 0)) { + newest = version; + } + } + } + return newest; + } catch (Exception failure) { + logFallback("Could not read Chrome known-good metadata: " + safe(failure)); + return null; + } + } + + private static String install(String browser, String version) throws IOException { + Path cache = cacheDirectory(); + Path directory = cache.resolve(driverFolder(browser)).resolve(cachePlatform(browser)).resolve(version); + Path binary = binaryIn(directory, browser); + if (binary != null) { + return binary.toString(); + } + Path lockPath = cache.resolve(".pkb-locks").resolve(driverFolder(browser) + "-" + cachePlatform(browser) + "-" + version + ".lock"); + Files.createDirectories(lockPath.getParent()); + try (var channel = java.nio.channels.FileChannel.open(lockPath, + java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE)) { + long deadline = System.nanoTime() + TRANSFER_CAP.toNanos(); + java.nio.channels.FileLock lock = null; + while (lock == null && System.nanoTime() < deadline) { + binary = binaryIn(directory, browser); + if (binary != null) { + return binary.toString(); + } + try { + lock = channel.tryLock(); + } catch (IOException overlap) { + lock = null; + } + if (lock == null) { + try { + Thread.sleep(200); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return null; + } + } + } + if (lock == null) { + logFallback("Timed out waiting for the " + browser + " " + version + " driver cache lock"); + return null; + } + try { + binary = binaryIn(directory, browser); + if (binary != null) { + return binary.toString(); + } + byte[] zip = transfer(zipUrls(browser, version)); + Path unzipped = Files.createTempDirectory(cache, "pkb-driver-"); + try { + unzip(zip, unzipped); + Path extracted = findBinary(unzipped, browser); + if (extracted == null) { + throw new IOException("driver binary missing from zip"); + } + Files.createDirectories(directory); + Path destination = directory.resolve(extracted.getFileName()); + Files.copy(extracted, destination, StandardCopyOption.REPLACE_EXISTING); + makeExecutable(destination); + return destination.toString(); + } finally { + deleteTree(unzipped); + } + } finally { + lock.release(); + } + } + } + + private static List zipUrls(String browser, String version) { + if ("edge".equals(browser)) { + return edgeZipUrls(version, edgeZipLabel()); + } + return chromeZipUrls(version, chromePlatform()); + } + + private static byte[] getQuiet(List urls) { + try { + return transfer(urls); + } catch (Throwable failure) { + logFallback("Driver metadata was not read from " + hostOf(urls.isEmpty() ? "" : urls.get(0)) + ": " + safe(failure)); + return null; + } + } + + private static byte[] transfer(List urls) throws IOException { + Transfer hook = transferOverride; + if (hook != null) { + return hook.get(urls); + } + return transferReal(urls); + } + + private static byte[] transferReal(List urls) throws IOException { + if (urls == null || urls.isEmpty()) { + throw new IOException("no driver download url"); + } + Probe probe = ensureProxy(urls.get(0)); + if (probe.connectionFailed()) { + throw new IOException("no usable driver-download connection"); + } + if (probe.useful()) { + return probe.body(); + } + IOException last = new IOException("download failed"); + for (int i = probe.firstUrlUsed() ? 1 : 0; i < urls.size(); i++) { + try { + FetchResult result = fetch(CHOSEN.get(), urls.get(i)); + if (result.blocked()) { + last = new IOException("blocked host " + hostOf(urls.get(i))); + continue; + } + if (result.useful()) { + return result.body(); + } + last = new IOException("HTTP " + result.status() + " from " + hostOf(urls.get(i))); + } catch (IOException failure) { + last = failure; + if (connectionFailure(failure)) { + break; + } + } + } + throw last; + } + + private static Probe ensureProxy(String probeUrl) { + ChosenProxy existing = CHOSEN.get(); + if (existing != null) { + return existing.unusable() ? Probe.none() : Probe.ready(); + } + synchronized (DISCOVERY) { + existing = CHOSEN.get(); + if (existing != null) { + return existing.unusable() ? Probe.none() : Probe.ready(); + } + List candidates = proxyCandidates( + property(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY), + System.getenv(), + osSystemProxies(), + osLowerProxies() + ); + for (ProxyCandidate candidate : candidates) { + ChosenProxy chosen = ChosenProxy.from(candidate, corporateCa()); + if (chosen == null) { + logFallback("Skipped unreadable " + candidate.source() + " proxy"); + continue; + } + try { + FetchResult result = fetch(chosen, probeUrl); + CHOSEN.set(chosen); + if (result.useful()) { + return Probe.hit(result.body()); + } + return Probe.usedFirst(); + } catch (Exception failure) { + logFallback("Driver download connection " + candidate.source() + " failed: " + safe(failure)); + } + } + CHOSEN.set(ChosenProxy.none()); + return Probe.none(); + } + } + + private static FetchResult fetch(ChosenProxy chosen, String url) throws IOException { + if (chosen == null || chosen.unusable()) { + throw new IOException("no driver download proxy"); + } + if (chosen.integrated()) { + return fetchIntegrated(chosen, url); + } + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(TRANSFER_CAP) + .header("Accept", "*/*") + .GET() + .build(); + HttpResponse response; + try { + response = chosen.client().send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("download interrupted", interrupted); + } + int status = response.statusCode(); + if (status == 407 && windows() && chosen.endpoint() != null && chosen.endpoint().user() == null) { + closeQuietly(response.body()); + FetchResult integrated = fetchIntegrated(chosen, url); + if (integrated.useful()) { + chosen.markIntegrated(); + return integrated; + } + throw new IOException("proxy login failed"); + } + byte[] body = readCapped(response.body()); + if (status == 403 || status == 401 || blockPage(status, body)) { + return FetchResult.blocked(status); + } + return new FetchResult(status, body, false); + } + + private static FetchResult fetchIntegrated(ChosenProxy chosen, String url) throws IOException { + if (!windows() || chosen.endpoint() == null || chosen.endpoint().isDirect()) { + throw new IOException("integrated proxy login is only used for a Windows proxy"); + } + Path out = Files.createTempFile("pkb-driver-", ".bin"); + try { + String proxy = "http://" + chosen.endpoint().host() + ":" + chosen.endpoint().port(); + String script = "$ProgressPreference='SilentlyContinue';" + + "$req=[System.Net.HttpWebRequest]::Create('" + ps(url) + "');" + + "$req.Timeout=20000;$req.ReadWriteTimeout=20000;" + + "$req.Proxy=New-Object System.Net.WebProxy('" + ps(proxy) + "',$true);" + + "$req.Proxy.Credentials=[System.Net.CredentialCache]::DefaultNetworkCredentials;" + + "$resp=$req.GetResponse();" + + "$out=[System.IO.File]::Create('" + ps(out.toString()) + "');" + + "$resp.GetResponseStream().CopyTo($out);$out.Close();$resp.Close();"; + Integer exit = runCommand(TRANSFER_CAP, "powershell", "-NoProfile", "-NonInteractive", "-Command", script); + if (exit == null || exit != 0 || !Files.isRegularFile(out) || Files.size(out) == 0) { + throw new IOException("Windows current-user proxy download failed"); + } + byte[] body = Files.readAllBytes(out); + if (body.length > MAX_BYTES) { + throw new IOException("download exceeded size cap"); + } + return new FetchResult(200, body, false); + } finally { + Files.deleteIfExists(out); + } + } + + private static byte[] readCapped(InputStream input) throws IOException { + long deadline = System.nanoTime() + TRANSFER_CAP.toNanos(); + Thread reader = Thread.currentThread(); + Thread cap = new Thread(() -> { + try { + Thread.sleep(TRANSFER_CAP.toMillis()); + } catch (InterruptedException ignored) { + return; + } + closeQuietly(input); + reader.interrupt(); + }, "pkb-driver-download-cap"); + cap.setDaemon(true); + cap.start(); + try (input) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + while (true) { + if (System.nanoTime() > deadline) { + throw new IOException("download exceeded 20s cap"); + } + int read = input.read(buffer); + if (read < 0) { + break; + } + if (out.size() + read > MAX_BYTES) { + throw new IOException("download exceeded size cap"); + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException failure) { + if (Thread.currentThread().isInterrupted() || System.nanoTime() > deadline) { + throw new IOException("download exceeded 20s cap", failure); + } + throw failure; + } finally { + cap.interrupt(); + Thread.interrupted(); + } + } + + private static boolean blockPage(int status, byte[] body) { + if (status == 403 || status == 401) { + return true; + } + if (status < 200 || status >= 300 || body == null || body.length == 0 || looksLikeZip(body)) { + return false; + } + String prefix = decodeEdgeVersionText(body.length > 512 ? java.util.Arrays.copyOf(body, 512) : body) + .toLowerCase(Locale.ROOT); + return prefix.contains("= 4 + && body[0] == 'P' && body[1] == 'K' && body[2] == 3 && body[3] == 4; + } + + private static boolean connectionFailure(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ConnectException + || current instanceof UnknownHostException + || current instanceof HttpConnectTimeoutException + || current instanceof HttpTimeoutException + || current instanceof javax.net.ssl.SSLException + || current instanceof CertificateException) { + return true; + } + String message = current.getMessage() == null ? "" : current.getMessage().toLowerCase(Locale.ROOT); + if (message.contains("proxy login") || message.contains("timed out") || message.contains("certificate")) { + return true; + } + current = current.getCause(); + } + return false; + } + + private static List osSystemProxies() { + try { + if (windows()) { + return windowsInternetProxy(); + } + if (mac()) { + return scutilProxy(); + } + return linuxEnvironmentProxies(); + } catch (Throwable failure) { + logFallback("OS proxy discovery skipped: " + safe(failure)); + return List.of(); + } + } + + private static List osLowerProxies() { + try { + if (windows()) { + return netshProxy(); + } + if (mac()) { + return networkSetupProxy(); + } + return gnomeProxy(); + } catch (Throwable failure) { + logFallback("Lower-level OS proxy discovery skipped: " + safe(failure)); + return List.of(); + } + } + + private static List linuxEnvironmentProxies() { + List found = new ArrayList<>(); + for (String key : List.of("http_proxy", "HTTP_PROXY", "ALL_PROXY", "all_proxy")) { + String value = System.getenv(key); + if (value != null && !value.isBlank()) { + found.add(value.trim()); + } + } + return found; + } + + private static List windowsInternetProxy() { + String enabled = reg("HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "ProxyEnable"); + if (enabled == null || enabled.endsWith("0x0") || enabled.endsWith("0")) { + return List.of(); + } + String server = reg("HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "ProxyServer"); + String value = registryValue(server); + if (value == null || value.isBlank()) { + return List.of(); + } + String https = proxyToken(value, "https"); + return List.of(https == null ? value : https); + } + + private static List netshProxy() { + String output = commandOutput(Duration.ofSeconds(3), "netsh", "winhttp", "show", "proxy"); + if (output == null || output.toLowerCase(Locale.ROOT).contains("direct access")) { + return List.of(); + } + Matcher matcher = Pattern.compile("(?i)proxy server\\(s\\)\\s*:\\s*(\\S+)").matcher(output); + if (!matcher.find()) { + return List.of(); + } + String token = matcher.group(1); + String https = proxyToken(token, "https"); + return List.of(https == null ? token : https); + } + + private static List scutilProxy() { + String output = commandOutput(Duration.ofSeconds(3), "scutil", "--proxy"); + if (output == null) { + return List.of(); + } + String host = scutilValue(output, "HTTPSProxy"); + String port = scutilValue(output, "HTTPSPort"); + String enabled = scutilValue(output, "HTTPSEnable"); + if (!"1".equals(enabled) || host == null || port == null) { + host = scutilValue(output, "HTTPProxy"); + port = scutilValue(output, "HTTPPort"); + enabled = scutilValue(output, "HTTPEnable"); + } + if (!"1".equals(enabled) || host == null || host.isBlank() || port == null) { + return List.of(); + } + return List.of("http://" + host + ":" + port); + } + + private static List networkSetupProxy() { + String services = commandOutput(Duration.ofSeconds(3), "networksetup", "-listallnetworkservices"); + if (services == null) { + return List.of(); + } + for (String service : services.split("\n")) { + String name = service.trim(); + if (name.isEmpty() || name.startsWith("An asterisk") || name.startsWith("*")) { + continue; + } + String output = commandOutput(Duration.ofSeconds(3), "networksetup", "-getsecurewebproxy", name); + if (output == null || output.toLowerCase(Locale.ROOT).contains("enabled: no")) { + continue; + } + Matcher host = Pattern.compile("(?i)server:\\s*(\\S+)").matcher(output); + Matcher port = Pattern.compile("(?i)port:\\s*(\\d+)").matcher(output); + if (host.find() && port.find()) { + return List.of("http://" + host.group(1) + ":" + port.group(1)); + } + } + return List.of(); + } + + private static List gnomeProxy() { + String mode = commandOutput(Duration.ofSeconds(3), "gsettings", "get", "org.gnome.system.proxy", "mode"); + if (mode == null || !mode.contains("manual")) { + return List.of(); + } + String host = gsettings("org.gnome.system.proxy.https", "host"); + String port = gsettings("org.gnome.system.proxy.https", "port"); + if (host == null || host.isBlank() || port == null || "0".equals(port)) { + return List.of(); + } + return List.of("http://" + host + ":" + port); + } + + private static String gsettings(String schema, String key) { + String value = commandOutput(Duration.ofSeconds(3), "gsettings", "get", schema, key); + if (value == null) { + return null; + } + return value.trim().replace("'", ""); + } + + private static String scutilValue(String output, String key) { + Matcher matcher = Pattern.compile("(?m)^\\s*" + Pattern.quote(key) + "\\s*:\\s*(\\S+)").matcher(output); + return matcher.find() ? matcher.group(1) : null; + } + + private static String reg(String key, String value) { + return commandOutput(Duration.ofSeconds(3), "reg", "query", key, "/v", value); + } + + private static String registryValue(String output) { + if (output == null) { + return null; + } + Matcher matcher = Pattern.compile("REG_\\w+\\s+(.+)$", Pattern.MULTILINE).matcher(output); + return matcher.find() ? matcher.group(1).trim() : null; + } + + private static String proxyToken(String value, String scheme) { + for (String part : value.split("[;\\s]+")) { + int eq = part.indexOf('='); + if (eq > 0 && part.substring(0, eq).equalsIgnoreCase(scheme)) { + return part.substring(eq + 1); + } + } + return value.contains("=") ? null : value; + } + + private static String installedBrowserVersion(String browser, String binary) { + VersionLookup hook = versionOverride; + if (hook != null) { + return hook.version(browser, binary); + } + try { + if (windows()) { + String exe = binary != null ? binary : findBrowser(browser); + String fileVersion = exe == null ? null : windowsFileVersion(exe); + if (fileVersion != null) { + return fileVersion; + } + String registered = registryBrowserVersion(browser); + if (registered != null) { + return registered; + } + } + String exe = binary != null ? binary : findBrowser(browser); + if (exe == null) { + return null; + } + return firstVersion(commandOutput(Duration.ofSeconds(5), exe, "--version")); + } catch (Throwable failure) { + logFallback("Installed " + browser + " version was not read: " + safe(failure)); + return null; + } + } + + private static String windowsFileVersion(String exe) { + String output = commandOutput(Duration.ofSeconds(5), "powershell", "-NoProfile", "-NonInteractive", "-Command", + "(Get-Item -LiteralPath '" + ps(exe) + "').VersionInfo.FileVersion"); + return firstVersion(output); + } + + private static String registryBrowserVersion(String browser) { + String relative = "edge".equals(browser) + ? "Microsoft\\Edge\\BLBeacon" + : "Google\\Chrome\\BLBeacon"; + for (String key : List.of( + "HKCU\\Software\\" + relative, + "HKLM\\SOFTWARE\\" + relative, + "HKLM\\SOFTWARE\\WOW6432Node\\" + relative + )) { + String version = firstVersion(reg(key, "version")); + if (version != null) { + return version; + } + } + return null; + } + + private static String findBrowser(String browser) { + boolean edge = "edge".equals(browser); + List candidates = new ArrayList<>(); + if (windows()) { + String programFiles = System.getenv("PROGRAMFILES"); + String programFiles86 = System.getenv("PROGRAMFILES(X86)"); + String local = System.getenv("LOCALAPPDATA"); + if (edge) { + addPath(candidates, programFiles, "Microsoft\\Edge\\Application\\msedge.exe"); + addPath(candidates, programFiles86, "Microsoft\\Edge\\Application\\msedge.exe"); + } else { + addPath(candidates, programFiles, "Google\\Chrome\\Application\\chrome.exe"); + addPath(candidates, programFiles86, "Google\\Chrome\\Application\\chrome.exe"); + addPath(candidates, local, "Google\\Chrome\\Application\\chrome.exe"); + } + } else if (mac()) { + candidates.add(edge + ? "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" + : "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + } else if (edge) { + candidates.addAll(List.of("microsoft-edge", "microsoft-edge-stable")); + } else { + candidates.addAll(List.of("google-chrome", "google-chrome-stable", "chromium", "chromium-browser")); + } + for (String candidate : candidates) { + if (candidate.contains("/") || candidate.contains("\\")) { + if (Files.isRegularFile(Path.of(candidate))) { + return candidate; + } + } else { + String resolved = commandOutput(Duration.ofSeconds(2), "which", candidate); + if (resolved != null && !resolved.isBlank()) { + return resolved.lines().findFirst().orElse("").trim(); + } + } + } + return null; + } + + private static void addPath(List candidates, String root, String child) { + if (root != null && !root.isBlank()) { + candidates.add(Path.of(root, child.split("\\\\")).toString()); + } + } + + private static Path cacheDirectory() { + String toml = null; + String system = System.getProperty("SE_CACHE_PATH"); + String env = System.getenv("SE_CACHE_PATH"); + Path home = Path.of(System.getProperty("user.home", ".")); + if (firstNonBlank(system, env) == null) { + Path config = home.resolve(".cache").resolve("selenium").resolve("se-config.toml"); + try { + if (Files.isRegularFile(config)) { + toml = Files.readString(config); + } + } catch (IOException failure) { + logFallback("Could not read se-config.toml: " + safe(failure)); + } + } + return resolveCacheDirectory(System.getenv(), system, home, toml); + } + + private static Path exactCached(String browser, String version) { + if (version == null) { + return null; + } + try { + return binaryIn(cacheDirectory().resolve(driverFolder(browser)).resolve(cachePlatform(browser)).resolve(version), browser); + } catch (Throwable failure) { + return null; + } + } + + private static Path highestCached(String browser, String major) { + if (major == null) { + return null; + } + try { + Path root = cacheDirectory().resolve(driverFolder(browser)).resolve(cachePlatform(browser)); + if (!Files.isDirectory(root)) { + return null; + } + String best = null; + Path bestBinary = null; + try (var paths = Files.list(root)) { + for (Path path : paths.toList()) { + String name = path.getFileName().toString(); + if (!name.startsWith(major + ".")) { + continue; + } + Path binary = binaryIn(path, browser); + if (binary != null && (best == null || compareVersions(name, best) > 0)) { + best = name; + bestBinary = binary; + } + } + } + return bestBinary; + } catch (Throwable failure) { + return null; + } + } + + private static Path binaryIn(Path directory, String browser) { + if (directory == null || !Files.isDirectory(directory)) { + return null; + } + String name = "edge".equals(browser) ? "msedgedriver" : "chromedriver"; + Path exe = directory.resolve(name + (windows() ? ".exe" : "")); + if (Files.isRegularFile(exe) && size(exe) > 0) { + return exe; + } + Path plain = directory.resolve(name); + if (Files.isRegularFile(plain) && size(plain) > 0) { + return plain; + } + return null; + } + + private static Path findBinary(Path root, String browser) throws IOException { + String name = "edge".equals(browser) ? "msedgedriver" : "chromedriver"; + try (var walk = Files.walk(root)) { + return walk.filter(Files::isRegularFile) + .filter(path -> { + String file = path.getFileName().toString().toLowerCase(Locale.ROOT); + return file.equals(name) || file.equals(name + ".exe"); + }) + .findFirst() + .orElse(null); + } + } + + private static void unzip(byte[] zip, Path destination) throws IOException { + if (!looksLikeZip(zip)) { + throw new IOException("download was not a zip"); + } + try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zip))) { + ZipEntry entry; + while ((entry = input.getNextEntry()) != null) { + Path target = destination.resolve(entry.getName()).normalize(); + if (!target.startsWith(destination)) { + throw new IOException("zip entry escaped the cache directory"); + } + if (entry.isDirectory()) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + + private static void makeExecutable(Path binary) { + try { + Set permissions = Files.getPosixFilePermissions(binary); + permissions.add(PosixFilePermission.OWNER_READ); + permissions.add(PosixFilePermission.OWNER_WRITE); + permissions.add(PosixFilePermission.OWNER_EXECUTE); + Files.setPosixFilePermissions(binary, permissions); + } catch (Exception ignored) { + // Windows and non-POSIX volumes have no mode bits. + } + try { + if (mac()) { + commandOutput(Duration.ofSeconds(3), "xattr", "-d", "com.apple.quarantine", binary.toString()); + } else if (windows()) { + commandOutput(Duration.ofSeconds(3), "powershell", "-NoProfile", "-NonInteractive", "-Command", + "Unblock-File -LiteralPath '" + ps(binary.toString()) + "'"); + } + } catch (Exception ignored) { + // Quarantine clearing is best-effort. + } + } + + private static String driverFolder(String browser) { + return "edge".equals(browser) ? "msedgedriver" : "chromedriver"; + } + + private static String chromePlatform() { + if (windows()) { + return "win64"; + } + if (mac()) { + return arm() ? "mac-arm64" : "mac-x64"; + } + return "linux64"; + } + + private static String cachePlatform(String browser) { + if (!"edge".equals(browser)) { + return chromePlatform(); + } + if (windows()) { + return "win64"; + } + if (mac()) { + return arm() ? "mac-arm64" : "mac64"; + } + return "linux64"; + } + + private static String edgeZipLabel() { + if (windows()) { + return "win64"; + } + if (mac()) { + return arm() ? "mac64_m1" : "mac64"; + } + return "linux64"; + } + + private static String edgeOsToken() { + if (windows()) { + return "WINDOWS"; + } + if (mac()) { + return "MACOS"; + } + return "LINUX"; + } + + private static boolean windows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + } + + private static boolean mac() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac"); + } + + private static boolean arm() { + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + return arch.contains("aarch64") || arch.contains("arm"); + } + + private static boolean majorOnly(String version) { + return version != null && version.indexOf('.') < 0; + } + + private static String majorOf(String version) { + if (version == null || version.isBlank()) { + return null; + } + String major = version.split("\\.")[0]; + for (int i = 0; i < major.length(); i++) { + if (!Character.isDigit(major.charAt(i))) { + return null; + } + } + return major.isEmpty() ? null : major; + } + + private static int compareVersions(String left, String right) { + String[] a = left.split("\\."); + String[] b = right.split("\\."); + int length = Math.max(a.length, b.length); + for (int i = 0; i < length; i++) { + int av = i < a.length ? parseInt(a[i]) : 0; + int bv = i < b.length ? parseInt(b[i]) : 0; + if (av != bv) { + return Integer.compare(av, bv); + } + } + return 0; + } + + private static int parseInt(String text) { + String digits = text.replaceAll("\\D.*", ""); + if (digits.isEmpty()) { + return 0; + } + try { + return Integer.parseInt(digits); + } catch (NumberFormatException failure) { + return 0; + } + } + + private static String firstVersion(byte[] body) { + return firstVersion(body == null ? null : decodeEdgeVersionText(body)); + } + + private static String firstVersion(String text) { + if (text == null) { + return null; + } + Matcher matcher = VERSION.matcher(text); + return matcher.find() ? matcher.group(1) : null; + } + + private static void addVersion(List versions, String version) { + if (version != null && VERSION.matcher(version).find()) { + versions.add(version.trim()); + } + } + + private static String property(String key) { + try { + PickleballRunner runner = PickleballRunner.rawInstance(); + if (runner != null) { + String resolved = runner.get(key); + if (resolved != null) { + return resolved; + } + } + } catch (Throwable ignored) { + // Driver launch can happen without a runner, and a runner failure must not fail the test. + } + return System.getProperty(key); + } + + private static Path corporateCa() { + String configured = text(property(PKB_props.PKB_DRIVER_DOWNLOAD_CA)); + if (configured == null) { + return null; + } + Path path = Path.of(configured); + return Files.isRegularFile(path) ? path : null; + } + + private static String cachePathFromToml(String toml) { + for (String line : toml.split("\n")) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || !trimmed.startsWith("cache-path")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq < 0) { + continue; + } + String value = unquoteToml(trimmed.substring(eq + 1).trim()); + if (!value.isBlank()) { + return value; + } + } + return null; + } + + private static String unquoteToml(String raw) { + String value = raw; + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length() - 1); + } + return value.replace("\\\\", "\\"); + } + + private static SSLContext sslContext(Path caFile) { + if (caFile == null) { + return null; + } + try { + TrustManagerFactory defaults = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + defaults.init((KeyStore) null); + KeyStore extraStore = KeyStore.getInstance(KeyStore.getDefaultType()); + extraStore.load(null, null); + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + int index = 0; + try (InputStream input = Files.newInputStream(caFile)) { + for (Certificate certificate : factory.generateCertificates(input)) { + extraStore.setCertificateEntry("pkb-driver-ca-" + index++, certificate); + } + } + if (index == 0) { + return null; + } + TrustManagerFactory extra = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + extra.init(extraStore); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[]{new CombinedTrust(defaults.getTrustManagers(), extra.getTrustManagers())}, null); + return context; + } catch (Exception failure) { + logFallback("Ignoring pkb_driver_download_ca: " + safe(failure)); + return null; + } + } + + private static String commandOutput(Duration timeout, String... command) { + try { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + if (!process.waitFor(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + return null; + } + byte[] output = process.getInputStream().readAllBytes(); + CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + try { + return decoder.decode(java.nio.ByteBuffer.wrap(output)).toString(); + } catch (CharacterCodingException failure) { + return new String(output, StandardCharsets.ISO_8859_1); + } + } catch (Exception failure) { + return null; + } + } + + private static Integer runCommand(Duration timeout, String... command) { + try { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + if (!process.waitFor(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + return null; + } + return process.exitValue(); + } catch (Exception failure) { + return null; + } + } + + private static void deleteTree(Path root) { + try (var walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Antivirus may still be scanning the extracted binary. + } + }); + } catch (IOException ignored) { + // Best-effort cleanup. + } + } + + private static long size(Path path) { + try { + return Files.size(path); + } catch (IOException failure) { + return 0; + } + } + + private static void closeQuietly(InputStream input) { + try { + if (input != null) { + input.close(); + } + } catch (IOException ignored) { + // Closing a blocked download is the cap. + } + } + + private static ExecutorService filler() { + ExecutorService existing = fillExecutor; + if (existing != null) { + return existing; + } + synchronized (DISCOVERY) { + if (fillExecutor == null) { + fillExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "pkb-driver-cache-fill"); + thread.setDaemon(true); + thread.setUncaughtExceptionHandler((ignored, failure) -> + logFallback("Driver cache fill failed: " + safe(failure))); + return thread; + }); + } + return fillExecutor; + } + } + + private static void addAll(List candidates, String source, List values) { + if (values == null) { + return; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + candidates.add(new ProxyCandidate(source, value.trim())); + } + } + } + + private static List dedupe(List candidates) { + Map unique = new LinkedHashMap<>(); + for (ProxyCandidate candidate : candidates) { + unique.putIfAbsent(candidate.value(), candidate); + } + return List.copyOf(unique.values()); + } + + private static String firstNonBlank(String first, String second) { + if (first != null && !first.isBlank()) { + return first.trim(); + } + if (second != null && !second.isBlank()) { + return second.trim(); + } + return null; + } + + private static String text(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private static String hostOf(String url) { + try { + String host = URI.create(url).getHost(); + return host == null ? "download" : host; + } catch (Exception failure) { + return "download"; + } + } + + private static String messageChain(Throwable failure) { + StringBuilder out = new StringBuilder(); + Throwable current = failure; + while (current != null && out.length() < 8000) { + if (current.getMessage() != null) { + out.append(current.getMessage()).append('\n'); + } + current = current.getCause(); + } + return out.toString(); + } + + private static String ps(String value) { + return value.replace("'", "''"); + } + + private static boolean looksLikeUtf16Le(byte[] body) { + int zeros = 0; + int samples = Math.min(body.length, 32); + for (int i = 1; i < samples; i += 2) { + if (body[i] == 0) { + zeros++; + } + } + return samples > 8 && zeros > samples / 4; + } + + private static String safe(Throwable failure) { + String message = failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage(); + return redactSecrets(message); + } + + static String redactSecrets(String value) { + if (value == null) { + return ""; + } + return value.replaceAll("(?i)(://)[^/\\s@]+@", "$1@"); + } + + private static void logFallback(String message) { + try { + logWarn(redactSecrets(message)); + } catch (Throwable ignored) { + // A logging failure must not become a test failure. + } + } + + private record FetchResult(int status, byte[] body, boolean blocked) { + static FetchResult blocked(int status) { + return new FetchResult(status, null, true); + } + + boolean useful() { + return !blocked && status >= 200 && status < 300 && body != null && body.length > 0; + } + } + + private record Probe(boolean connectionFailed, boolean firstUrlUsed, byte[] payload) { + static Probe none() { + return new Probe(true, false, null); + } + + static Probe hit(byte[] body) { + return new Probe(false, true, body); + } + + static Probe usedFirst() { + return new Probe(false, true, null); + } + + static Probe ready() { + return new Probe(false, false, null); + } + + boolean useful() { + return payload != null && payload.length > 0; + } + + byte[] body() { + return payload; + } + } + + private static final class ChosenProxy { + private final ProxyEndpoint endpoint; + private final HttpClient client; + private final boolean failed; + private volatile boolean integrated; + + private ChosenProxy(ProxyEndpoint endpoint, HttpClient client, boolean failed) { + this.endpoint = endpoint; + this.client = client; + this.failed = failed; + } + + static ChosenProxy none() { + return new ChosenProxy(null, null, true); + } + + static ChosenProxy from(ProxyCandidate candidate, Path caFile) { + if (DIRECT.equals(candidate.value())) { + return new ChosenProxy(ProxyEndpoint.direct(), client(null, null, null, caFile), false); + } + ProxyEndpoint endpoint = ProxyEndpoint.parse(candidate.value()); + if (endpoint == null) { + return null; + } + return new ChosenProxy(endpoint, client(endpoint.host(), endpoint.port(), endpoint, caFile), false); + } + + ProxyEndpoint endpoint() { + return endpoint; + } + + HttpClient client() { + return client; + } + + boolean unusable() { + return failed; + } + + boolean integrated() { + return integrated; + } + + void markIntegrated() { + integrated = true; + } + + private static HttpClient client(String host, Integer port, ProxyEndpoint endpoint, Path caFile) { + HttpClient.Builder builder = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL) + .proxy(proxySelector(host, port)); + SSLContext context = sslContext(caFile); + if (context != null) { + builder.sslContext(context); + } + if (endpoint != null && endpoint.user() != null) { + builder.authenticator(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() == RequestorType.PROXY) { + String password = endpoint.password() == null ? "" : endpoint.password(); + return new PasswordAuthentication(endpoint.user(), password.toCharArray()); + } + return null; + } + }); + } + return builder.build(); + } + + private static ProxySelector proxySelector(String host, Integer port) { + if (host == null || port == null) { + return new ProxySelector() { + @Override + public List select(URI uri) { + return List.of(Proxy.NO_PROXY); + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + } + }; + } + return ProxySelector.of(new InetSocketAddress(host, port)); + } + } + + private record ProxyEndpoint(String host, int port, String user, String password, boolean isDirect) { + static ProxyEndpoint direct() { + return new ProxyEndpoint(null, 0, null, null, true); + } + + static ProxyEndpoint parse(String raw) { + if (raw == null || raw.isBlank() || DIRECT.equalsIgnoreCase(raw.trim())) { + return direct(); + } + String value = raw.trim(); + if (!value.contains("://")) { + value = "http://" + value; + } + try { + URI uri = URI.create(value); + if (uri.getHost() == null) { + return null; + } + String userInfo = uri.getUserInfo(); + String user = null; + String password = null; + if (userInfo != null) { + int colon = userInfo.indexOf(':'); + if (colon >= 0) { + user = decode(userInfo.substring(0, colon)); + password = decode(userInfo.substring(colon + 1)); + } else { + user = decode(userInfo); + } + } + int port = uri.getPort() > 0 ? uri.getPort() : 80; + return new ProxyEndpoint(uri.getHost(), port, user, password, false); + } catch (IllegalArgumentException failure) { + return null; + } + } + + private static String decode(String value) { + try { + return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException failure) { + return value; + } + } + } + + private static final class CombinedTrust implements X509TrustManager { + private final List managers = new ArrayList<>(); + + CombinedTrust(TrustManager[] defaults, TrustManager[] extra) { + add(defaults); + add(extra); + } + + private void add(TrustManager[] managers) { + if (managers == null) { + return; + } + for (TrustManager manager : managers) { + if (manager instanceof X509TrustManager x509) { + this.managers.add(x509); + } + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + CertificateException last = null; + for (X509TrustManager manager : managers) { + try { + manager.checkClientTrusted(chain, authType); + return; + } catch (CertificateException failure) { + last = failure; + } + } + if (last != null) { + throw last; + } + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + CertificateException last = null; + for (X509TrustManager manager : managers) { + try { + manager.checkServerTrusted(chain, authType); + return; + } catch (CertificateException failure) { + last = failure; + } + } + if (last != null) { + throw last; + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + List issuers = new ArrayList<>(); + for (X509TrustManager manager : managers) { + X509Certificate[] accepted = manager.getAcceptedIssuers(); + if (accepted != null) { + issuers.addAll(List.of(accepted)); + } + } + return issuers.toArray(X509Certificate[]::new); + } + } +} diff --git a/src/main/java/tools/dscode/testengine/PKB_props.java b/src/main/java/tools/dscode/testengine/PKB_props.java index b00ee7b22..335137ae6 100644 --- a/src/main/java/tools/dscode/testengine/PKB_props.java +++ b/src/main/java/tools/dscode/testengine/PKB_props.java @@ -65,6 +65,9 @@ public final class PKB_props { public static final String PKB_DEBUG_BROWSER = PKB_PREFIX + "debugBrowser"; public static final String PKB_DEBUG_ARGS = PKB_PREFIX + "debugargs"; + public static final String PKB_DRIVER_DOWNLOAD_PROXY = PKB_PREFIX + "driver_download_proxy"; + public static final String PKB_DRIVER_DOWNLOAD_NATIVE = PKB_PREFIX + "driver_download_native"; + public static final String PKB_DRIVER_DOWNLOAD_CA = PKB_PREFIX + "driver_download_ca"; private static final Set RUN_METADATA_KEYS = Set.of( PKB_INVESTIGATION_ID, diff --git a/src/main/java/tools/dscode/testengine/SensitiveConfiguration.java b/src/main/java/tools/dscode/testengine/SensitiveConfiguration.java index bfee3543e..b77d2ecda 100644 --- a/src/main/java/tools/dscode/testengine/SensitiveConfiguration.java +++ b/src/main/java/tools/dscode/testengine/SensitiveConfiguration.java @@ -20,7 +20,8 @@ public final class SensitiveConfiguration { "pkb_rp_oauth_client_secret", "pkb_rp_keystore_password", "pkb_rp_truststore_password", - "pkb_rp_http_proxy_password" + "pkb_rp_http_proxy_password", + PKB_props.PKB_DRIVER_DOWNLOAD_PROXY ); /* Conservative fallback so new secret-like RunVars cannot bypass redaction before being audited above. */ diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index 33510ba41..5bc668513 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -176,6 +176,8 @@ pkb_scenarioReport=false Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. +Local Chrome and Edge do not need a `driverExecutable`. Selenium Manager runs first (`pkb_driver_download_native=false` stops that network use for the launch). If the manager cannot fetch a driver, a best-effort download may cache one. Discovery, a 403, a certificate failure, or an antivirus lock is a log line, not a test error. Do not treat that log as a product failure, and do not add a proxy example under `configs`. `pkb_driver_download_proxy=false` turns the fallback off. A URL in that property is redacted in `pkb_run_profile` and diagnostics. Remote browsers are unchanged. + These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: ```text 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 567b211cf..a22181563 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 @@ -15,7 +15,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Direct service-call control/evidence | `ServiceCallControl.java`; `ServiceCallEvidence.java`; `BoundedJsonEvidence.java`; `ServiceCallSteps.java`; `ModularScenarios.java`; `RestAssuredUtil.java`; bridge `/v1/services/call`; Workbench `workbench_service_call` | `@control-bridge`; existing service-call features | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/service-call-scenarios.md` | | Semantic breakpoints | `ControlBridgeCoordinator.java`; `ControlBridgeBreakpoint.java`; `ControlHook.java`; bridge `/v1/breakpoints*`; Workbench `workbench_breakpoint_*` | `@control-bridge`; Workbench UI/MCP tests and `live-check` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md` | | Dynamic steps/expression execution | `DynamicSteps.java`; `DynamicExecution.java`; `StepExtension.java`; tree-parsing classes; `PlaceHolderMatch`; `DefinitionContext.itPlaceholder` | `dynamic-steps.feature`; `forms-dynamic-steps.feature`; `it-placeholder.feature`; `PlaceHolderMatchChecks.java` | `docs/dynamic-steps.md` | -| Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | +| Selenium navigation and interaction | `BrowserSteps.java`; `NavigationSteps.java`; `ElementWrapper.java`; `HumanInteractions.java`; `SeleniumUtils.java`; `DriverConstruction.java`; `DriverDownloadFallback.java`; bundled `META-INF/pickleball/configs/CHROME_HEADLESS.yaml`; `ParsingMap.initializeConfigs` fallback | `navigation.feature`; `forms-dynamic-steps.feature`; `dialogs.feature`; `ChromeHeadlessConfigChecks.java`; `DriverDownloadFallbackTest.java`; browser test-site pages | `docs/dynamic-steps.md`; `docs/custom-element-definitions.md`; `docs/configuration.md`; `docs/config-files-and-resource-mapping.md` | | Custom element definitions/catalog context | `ExecutionDictionary.java`; `ElementMatch.java`; consumer `PickleballTests.java`; search `category(`, `inheritsFrom`, `addBase`, `reset`, `registerIframe`, `registerShadowRoot` | `catalog-context.feature`; `forms-dynamic-steps.feature`; `browser-action-contracts.feature`; `site/catalog.html` | `docs/custom-element-definitions.md`; `docs/dynamic-steps.md` | | Mapping, ParsingMap/NodeMap, templates/directives | `MappingSteps.java`; `FileAndDataParsing.java`; `MappingProcessor.java`; `QuoteParser.java`; `NodeMap.java`; `ParsingMap.java`; `ValueFormatting.java`; `common/dataelements`; `TableUtils.cellsWithRestoredBlanks`; optional `?` keys (`Col1` vs `?Col1`) | `mapping-and-resources.feature` (`@optional-keys`); `mapping-value-type-preservation.feature`; `scenario-data-references.feature`; `data-element-native-tabular.feature`; `DataTableConversionChecks.java`; `QuoteParserChecks.java`; internal Java checks | `docs/mapping-and-templating.md`; `docs/data-values-and-elements.md`; `docs/data-element-query-runtime.md`; `docs/config-files-and-resource-mapping.md`; `docs/component-scenarios.md` | | Configuration/profiles/RunVars | `PKB_props.java`; `PickleballProfiles.java`; `PkbPropertyValueNormalizer.java`; `ParallelCountEstimator.java`; `AgentBrowserLadder.java`; `AgentDiscoverPlanner.java`; runner/config classes; search `pkb_profile`, `pkb_runvars`, `pkb_overriderunvars`, `pkb_run_profile`, `pkb_configpath`, `RunConfigCopy`, `pkb_parallel=auto`, `pkb_example`; `ExampleRowFilter.java`; protocol `ExampleRowSelector`; `CucumberScanUtil.listPickles`; `PKB_props.resolveRunVars`, `PKB_props.overrideRunVars` | `configuration-system-properties.feature`; `example-row-proof.feature`; `syntax-since-2.1.14.feature`; `ProfileConfigurationChecks.java`; `RunVarOverrideChecks.java`; `ExampleRowFilterTest`; `ExampleRowSelectorTest`; `ExampleRowFilterChecks.java`; `@runvar-override`; `ParallelCountEstimatorChecks.java`; `AgentBrowserLadderChecks.java`; consumer properties/profile examples | `docs/configuration.md`; `docs/getting-started.md`; `docs/ai-run-configuration.md`; `docs/consumer-project.md` | diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md b/src/main/resources/META-INF/pickleball/guidance/docs/agent/repository-index.md index f6f0bcf54..29f585d5c 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 @@ -210,6 +210,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/main/java/tools/dscode/common/domoperations/SeleniumUtils.java` - `src/main/java/tools/dscode/common/domoperations/TableColumnByHeaderXPath.java` - `src/main/java/tools/dscode/common/driver/DriverConstruction.java` +- `src/main/java/tools/dscode/common/driver/DriverDownloadFallback.java` - `src/main/java/tools/dscode/common/evaluations/AviatorFunctions.java` - `src/main/java/tools/dscode/common/evaluations/AviatorUtil.java` - `src/main/java/tools/dscode/common/evaluations/BookendUtils.java` @@ -411,6 +412,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/common/assertions/AssertionClauseSplitterTest.java` - `src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java` - `src/test/java/tools/dscode/common/coordination/RunConfigIsolationTest.java` +- `src/test/java/tools/dscode/common/driver/DriverDownloadFallbackTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentBrowserLadderTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/AgentDiscoverPlannerTest.java` - `src/test/java/tools/dscode/common/reporting/diagnostic/ConsumerMavenTestRunnerTest.java` @@ -428,6 +430,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/launcher/WorkbenchSessionCommandsTest.java` - `src/test/java/tools/dscode/parallelutilities/ParallelCountEstimatorTest.java` - `src/test/java/tools/dscode/testengine/CucumberNameFilterTest.java` +- `src/test/java/tools/dscode/testengine/DriverDownloadRedactionTest.java` - `src/test/java/tools/dscode/testengine/DynamicSuiteBootstrapWorkbenchRootTest.java` - `src/test/java/tools/dscode/testengine/ExampleRowFilterTest.java` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index 21eb0454c..407bd0f61 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -430,7 +430,7 @@ When operating in a consumer project: ## AI agents -The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. +The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. A blocked local driver download does not fail the run: Selenium Manager is tried first unless `pkb_driver_download_native=false`, and the fallback is skipped only when `pkb_driver_download_proxy=false`. An explicit `driver.service.driverExecutable` skips both. The proxy value is redacted. Do not invent a `configs` proxy block and do not copy it onto `HTTPS_PROXY` or `SE_PROXY`. See [Execution Configuration](configuration.md). Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index 905a70c06..8807a1294 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -391,6 +391,9 @@ The existing path semantics for `pkb_features`, `pkb_datapath`, `pkb_callpath`, | `pkb_example` | `1 2 5 3.4 7-11` | Examples-row filter applied after tags and name. Not a tag | | `pkb_environment` | `QA` | project environment label | | `pkb_browser` | `chrome` | browser configuration name looked up under the `configs` mapping (`CHROME_HEADLESS` uses the consumer yaml when present, otherwise Pickleball's bundled headless Chrome) | +| `pkb_driver_download_native` | `true`, `false` | `true` or unset tries Selenium Manager first; `false` keeps that launch off the network | +| `pkb_driver_download_proxy` | empty, `false`, or `http://user:pass@host:port` | empty discovers a driver-download proxy; `false` disables the fallback; a URL is tried first and is redacted | +| `pkb_driver_download_ca` | PEM file path | optional corporate CA for the driver downloader only | | `pkb_profile` | `qa,browser_firefox` | selected named profile(s) | | `pkb_runvars` | `pkb_tags=@smoke, pkb_browser=chrome` | compact controlled RunVar input | | `pkb_runvars.` | `pkb_runvars.pkb_browser=chrome` | expanded controlled RunVar member | @@ -450,6 +453,33 @@ The resolved integer is stamped into the final RunVars and `pkb_run_profile`. Wo The bundled headless config uses `--headless=new`, a fixed `--window-size=1920,1080`, no `MAXIMIZE`, and `QUIT_LOCAL_DRIVER`. Consumer `CHROME`, `EDGE`, `GRID`, and `SAUCE` yaml files are unchanged. Agents can set `pkb_browser=CHROME_HEADLESS` without copying yaml into the project. +## Local driver download + +Local Chrome and Edge start through Selenium. Remote WebDriver is unchanged. Bundled and consumer `CHROME_HEADLESS` configs set `driver.service.port` and `driver.service.verbose` and do not set `driver.service.driverExecutable`. Selenium Manager then resolves the driver. Behind a corporate proxy that child often cannot log in, trust a re-signed certificate, or reach `storage.googleapis.com`. + +Pickleball keeps a separate best-effort download for that case. It does not add a proxy block under `configs`, and it does not copy the download proxy onto `http.proxyHost`, `https.proxyHost`, `HTTPS_PROXY`, or `SE_PROXY`. Browser `--proxy-server` and `pkb_rp_http_proxy_*` stay on their own channels. + +| Property | Values | Meaning | +|---|---|---| +| `pkb_driver_download_native` | `true` or unset | Try Selenium Manager first | +| `pkb_driver_download_native` | `false` | Do not let Selenium Manager use the network for this launch. Use a cached or fallback executable. `SE_OFFLINE` is set only around that launch | +| `pkb_driver_download_proxy` | empty or unset | Discover a proxy, then try a direct connection | +| `pkb_driver_download_proxy` | `false` | Do not run the fallback. `false` is not a hostname | +| `pkb_driver_download_proxy` | one `http://user:pass@host:port` line | Try this proxy first. Percent-encode a password that contains `@` or `:` | +| `pkb_driver_download_ca` | optional PEM file | Corporate CA used only by the downloader. It is not installed into the JVM trust store | + +`pkb_driver_download_proxy` is redacted everywhere a run profile, diagnostic, or log can show it, the same way `pkb_rp_http_proxy_password` is redacted. + +Native then fallback is the default. Fallback only, native only, and neither are valid. Neither requires `driver.service.driverExecutable`. If both are disabled and no executable is set, startup fails and the message names those two properties. An executable that is already set skips the download. + +Discovery runs once per JVM. The order is the `pkb_driver_download_proxy` value, then `HTTPS_PROXY` and `SE_PROXY`, then the OS system proxy, then the lower-level OS setting, then a direct connection. A connection, login, or certificate failure tries the next proxy. HTTP 403 or a block page after a successful handshake keeps that proxy and tries the next hostname. Direct is the last proxy configuration, not another hostname. Windows may use WinHTTP or a short PowerShell `-UseDefaultCredentials` helper for current-user proxy login. macOS reads `scutil --proxy`. Linux uses the environment, and a GNOME `gsettings` read is optional. A missing tool is skipped. + +Chrome metadata comes from `googlechromelabs.github.io/chrome-for-testing` (`LATEST_RELEASE_STABLE`, `LATEST_RELEASE_`, the known-good and milestone JSON files). Chrome zips are tried as `commondatastorage.googleapis.com`, then `storage.googleapis.com`, then `edgedl.me.gvt1.com`. Edge metadata is UTF-16 LE. The version URL is `https://msedgedriver.microsoft.com/LATEST_RELEASE__WINDOWS` (or `_LINUX` / `_MACOS`), and the zip is `edgedriver_win64.zip`, `edgedriver_mac64.zip`, `edgedriver_mac64_m1.zip`, or `edgedriver_linux64.zip`. Pickleball does not use `chromedriver.storage.googleapis.com`, `msedgedriver.azureedge.net`, or `msedgewebdriverstorage.blob.core.windows.net`. + +The installed browser major wins: Windows file version or registry, and `--version` elsewhere. A Selenium Manager failure that already names a versioned zip is retried on the other hosts. `SessionNotCreatedException` text `Current browser version is ...` is parsed and retried once for that major. If the session starts and the driver only logs that it has not been tested with this browser, the test keeps that driver and caches the matching major for the next launch. It does not replace the open executable. With no version at all, only the current Stable driver is used, and only when no driver is already running. The last 8 stable majors are a cache fill for that browser, counted separately for Chrome and Edge. A major already in the cache is skipped. The lock is per browser, platform, and version. The launch never points `driverExecutable` at one of the other seven. + +Files go in Selenium Manager's cache: the `SE_CACHE_PATH` system property, otherwise the `SE_CACHE_PATH` environment variable, otherwise `cache-path` in `~/.cache/selenium/se-config.toml`, otherwise `~/.cache/selenium`, under `chromedriver///` or `msedgedriver///`. The launch also sets `driverExecutable` to the unzipped binary. Each transfer is capped at 20 seconds. A blocked transfer, unzip error, quarantine flag, antivirus lock, or certificate failure is a log line. It does not fail the test. + ## Agent Discover browser ladder Workbench Discover/Confirm do not blindly MUST-use `CHROME_HEADLESS` for every project: diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index 33510ba41..5bc668513 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -176,6 +176,8 @@ pkb_scenarioReport=false Use the narrowest `pkb_tags` / `pkb_name` / `pkb_example` that isolate the failure. `pkb_example` selects Examples rows after tags and name. It is not a tag. A normal Scenario counts as row 1, and `--example='1 2 5 3.4 7-11'` is the list form. Do not add the `pretty` plugin; it is console noise for agents. Discover defaults to `pkb_reportretention=failed`, which keeps dense evidence for failing scenarios and does not retain it for passing ones. Override with Workbench `--retention=all|failed|none`. Workbench `hint` prints the estimated integer `pkb_parallel` and the selected browser for the current project/JVM. `pkb_parallel=auto` also resolves to that estimate at run start and stamps the integer into `pkb_run_profile`. +Local Chrome and Edge do not need a `driverExecutable`. Selenium Manager runs first (`pkb_driver_download_native=false` stops that network use for the launch). If the manager cannot fetch a driver, a best-effort download may cache one. Discovery, a 403, a certificate failure, or an antivirus lock is a log line, not a test error. Do not treat that log as a product failure, and do not add a proxy example under `configs`. `pkb_driver_download_proxy=false` turns the fallback off. A URL in that property is redacted in `pkb_run_profile` and diagnostics. Remote browsers are unchanged. + These are documented agent defaults, not `PickleballTests` human defaults (`pretty`, `@all`, often headed Chrome). Example confirmation after Discover: ```text diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md b/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md index 9f5f94355..f9a79d3e2 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/getting-started.md @@ -123,6 +123,8 @@ qa: pkb_browser: CHROME_HEADLESS ``` +`pkb_browser` selects a yaml config. Local Chrome and Edge still start through Selenium. When that config has no `driver.service.driverExecutable`, Selenium Manager runs first. If it cannot download a driver, Pickleball tries a best-effort Chrome for Testing or Edge driver and caches it. A blocked download is a log line, not a test failure. Set `pkb_driver_download_proxy=false` to skip that fallback, or `pkb_driver_download_native=false` to keep Selenium Manager off the network for the launch. Do not put the download proxy in the browser yaml. See [Execution Configuration](configuration.md). + ```bash ./mvnw test -Dpkb_profile=qa ``` diff --git a/src/test/java/tools/dscode/common/driver/DriverDownloadFallbackTest.java b/src/test/java/tools/dscode/common/driver/DriverDownloadFallbackTest.java new file mode 100644 index 000000000..c2ea95ec6 --- /dev/null +++ b/src/test/java/tools/dscode/common/driver/DriverDownloadFallbackTest.java @@ -0,0 +1,469 @@ +package tools.dscode.common.driver; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.SessionNotCreatedException; + +import java.io.ByteArrayOutputStream; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DriverDownloadFallbackTest { + + @AfterEach + void reset() { + DriverDownloadFallback.resetForTests(); + System.clearProperty("pkb_driver_download_proxy"); + System.clearProperty("pkb_driver_download_native"); + System.clearProperty("pkb_driver_download_ca"); + System.clearProperty("SE_CACHE_PATH"); + } + + @Test + void proxyPropertyParsesEmptyFalseAndUrl() { + assertEquals(DriverDownloadFallback.ProxyMode.DISCOVER, DriverDownloadFallback.proxyMode(null)); + assertEquals(DriverDownloadFallback.ProxyMode.DISCOVER, DriverDownloadFallback.proxyMode(" ")); + assertEquals(DriverDownloadFallback.ProxyMode.DISABLED, DriverDownloadFallback.proxyMode("false")); + assertEquals(DriverDownloadFallback.ProxyMode.DISABLED, DriverDownloadFallback.proxyMode(" FALSE ")); + assertEquals(DriverDownloadFallback.ProxyMode.EXPLICIT, + DriverDownloadFallback.proxyMode("http://user:p%40ss@proxy.example:8080")); + assertEquals(DriverDownloadFallback.ProxyMode.EXPLICIT, DriverDownloadFallback.proxyMode("http://false:8080")); + + assertTrue(DriverDownloadFallback.nativeEnabled(null)); + assertTrue(DriverDownloadFallback.nativeEnabled("")); + assertTrue(DriverDownloadFallback.nativeEnabled("true")); + assertFalse(DriverDownloadFallback.nativeEnabled("false")); + } + + @Test + void falseIsNotAProxyHostAndDiscoveryOrderEndsAtDirect() { + Map environment = Map.of( + "HTTPS_PROXY", "http://env-proxy:8080", + "SE_PROXY", "http://se-proxy:8080" + ); + assertTrue(DriverDownloadFallback.proxyCandidates( + "false", environment, List.of("http://os:8080"), List.of("http://lower:8080")).isEmpty()); + + List discovered = DriverDownloadFallback.proxyCandidates( + " ", environment, List.of("http://os:8080"), List.of("http://lower:8080")); + assertEquals(List.of( + "HTTPS_PROXY", + "SE_PROXY", + "os-proxy", + "os-lower", + DriverDownloadFallback.DIRECT + ), discovered.stream().map(DriverDownloadFallback.ProxyCandidate::source).toList()); + assertEquals(DriverDownloadFallback.DIRECT, discovered.get(discovered.size() - 1).value()); + + List explicit = DriverDownloadFallback.proxyCandidates( + "http://user:secret@pkb:8080", environment, List.of(), List.of()); + assertEquals("pkb_driver_download_proxy", explicit.get(0).source()); + assertEquals("http://user:secret@pkb:8080", explicit.get(0).value()); + assertEquals(DriverDownloadFallback.DIRECT, explicit.get(explicit.size() - 1).value()); + } + + @Test + void chromeZipHostsStayInTheVerifiedOrder() { + List urls = DriverDownloadFallback.chromeZipUrls("131.0.6778.85", "linux64"); + assertEquals(List.of( + "https://commondatastorage.googleapis.com/chrome-for-testing-public/131.0.6778.85/linux64/chromedriver-linux64.zip", + "https://storage.googleapis.com/chrome-for-testing-public/131.0.6778.85/linux64/chromedriver-linux64.zip", + "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/131.0.6778.85/linux64/chromedriver-linux64.zip" + ), urls); + String joined = String.join("\n", urls); + assertFalse(joined.contains("chromedriver.storage.googleapis.com")); + assertFalse(joined.contains("msedgedriver.azureedge.net")); + assertFalse(joined.contains("blob.core.windows.net")); + } + + @Test + void edgeMetadataUsesAnUnderscoreAndTheLiveHost() { + assertEquals( + "https://msedgedriver.microsoft.com/LATEST_RELEASE_154_LINUX", + DriverDownloadFallback.edgeMajorMetadataUrl("154", "LINUX")); + List zips = DriverDownloadFallback.edgeZipUrls("154.0.4258.62", "linux64"); + assertEquals(List.of("https://msedgedriver.microsoft.com/154.0.4258.62/edgedriver_linux64.zip"), zips); + String joined = zips.get(0) + DriverDownloadFallback.edgeMajorMetadataUrl("154", "WINDOWS"); + assertFalse(joined.contains("LATEST_RELEASE_154/")); + assertFalse(joined.contains("LATEST_RELEASE_154WINDOWS")); + assertFalse(joined.contains("azureedge.net")); + assertFalse(joined.contains("blob.core.windows.net")); + } + + @Test + void edgeVersionTextIsUtf16LeWithBomAndCrlf() { + byte[] encoded = "131.0.2903.51\r\n".getBytes(StandardCharsets.UTF_16LE); + byte[] withBom = new byte[encoded.length + 2]; + withBom[0] = (byte) 0xFF; + withBom[1] = (byte) 0xFE; + System.arraycopy(encoded, 0, withBom, 2, encoded.length); + assertEquals("131.0.2903.51", DriverDownloadFallback.decodeEdgeVersionText(withBom)); + assertEquals("131.0.6778.85", DriverDownloadFallback.decodeEdgeVersionText( + "131.0.6778.85\n".getBytes(StandardCharsets.US_ASCII))); + } + + @Test + void sessionNotCreatedExceptionExposesTheCurrentBrowserVersion() { + SessionNotCreatedException failure = new SessionNotCreatedException( + "session not created: This version of ChromeDriver only supports Chrome version 114\n" + + "Current browser version is 132.0.6834.83 with binary path /opt/google/chrome/chrome"); + assertEquals("132.0.6834.83", DriverDownloadFallback.browserVersionFromSessionMessage(failure)); + assertTrue(DriverDownloadFallback.isSessionVersionMismatch(failure)); + assertEquals("131.0.6778.204", DriverDownloadFallback.versionFromDriverDownloadUrl( + "error downloading https://storage.googleapis.com/chrome-for-testing-public/131.0.6778.204/linux64/chromedriver-linux64.zip")); + RuntimeException wrapped = new RuntimeException("launch failed", failure); + assertEquals("132.0.6834.83", DriverDownloadFallback.versionFromFailure(wrapped)); + } + + @Test + void cachePathPrefersTheSeleniumManagerLocation() throws IOException { + Path home = Files.createTempDirectory("pkb-cache-home"); + assertEquals(Path.of("/custom/selenium"), DriverDownloadFallback.resolveCacheDirectory( + Map.of("SE_CACHE_PATH", "/custom/selenium"), + null, + home, + "cache-path = \"/from/toml\"")); + assertEquals(Path.of("/from/property"), DriverDownloadFallback.resolveCacheDirectory( + Map.of("SE_CACHE_PATH", "/from/env"), + "/from/property", + home, + "cache-path = \"/from/toml\"")); + assertEquals(Path.of("/from/toml"), DriverDownloadFallback.resolveCacheDirectory( + Map.of(), + " ", + home, + "cache-path = \"/from/toml\"\n")); + assertEquals(home.resolve(".cache").resolve("selenium"), DriverDownloadFallback.resolveCacheDirectory( + Map.of(), null, home, "# no cache\n")); + assertEquals(home.resolve(".cache").resolve("selenium"), DriverDownloadFallback.resolveCacheDirectory( + Map.of(), null, home, "cache-path = \"~/.cache/selenium\"")); + } + + @Test + void downloadAndAntivirusIoExceptionsDoNotEscape() throws IOException { + Path cache = Files.createTempDirectory("pkb-driver-cache"); + System.setProperty("SE_CACHE_PATH", cache.toString()); + System.setProperty("pkb_driver_download_native", "false"); + System.setProperty("pkb_driver_download_proxy", "http://127.0.0.1:9"); + DriverDownloadFallback.useVersionForTests((browser, binary) -> "120.0.6099.109"); + DriverDownloadFallback.useTransferForTests(urls -> { + throw new IOException("Operation did not complete successfully because the file contains a virus or potentially unwanted software"); + }); + Map service = new LinkedHashMap<>(); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("chrome", service, null)); + assertFalse(service.containsKey("driverExecutable")); + + DriverDownloadFallback.useTransferForTests(urls -> "not-a-zip".getBytes(StandardCharsets.US_ASCII)); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("edge", service, null)); + assertNull(service.get("driverExecutable")); + } + + @Test + void bothDisabledWithoutAnExecutableFailsClosedAndSaysWhy() { + System.setProperty("pkb_driver_download_native", "false"); + System.setProperty("pkb_driver_download_proxy", "false"); + Map service = new LinkedHashMap<>(); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> DriverDownloadFallback.prepareLaunch("chrome", service, null)); + assertTrue(failure.getMessage().contains("driver.service.driverExecutable")); + assertTrue(failure.getMessage().contains("pkb_driver_download_native=false")); + assertTrue(failure.getMessage().contains("pkb_driver_download_proxy=false")); + + service.put("driverExecutable", "/opt/drivers/chromedriver"); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("chrome", service, null)); + assertEquals("/opt/drivers/chromedriver", service.get("driverExecutable")); + } + + @Test + void loggedProxySecretsAreRedacted() { + assertEquals("connecting to http://@proxy:8080", + DriverDownloadFallback.redactSecrets("connecting to http://user:secret@proxy:8080")); + assertFalse(DriverDownloadFallback.redactSecrets("http://user:p%40ss@proxy:9").contains("p%40ss")); + } + + @Test + void knownMajorIsNotAnArbitraryCachedNeighbor() throws IOException { + isolatedCache(); + for (int major = 120; major <= 127; major++) { + seed("chrome", major + ".0.1.1"); + } + seed("chrome", "131.0.1.1"); + System.setProperty("pkb_driver_download_native", "false"); + System.setProperty("pkb_driver_download_proxy", "http://127.0.0.1:9"); + DriverDownloadFallback.useVersionForTests((browser, binary) -> "131.0.6778.85"); + DriverDownloadFallback.useTransferForTests(urls -> { + throw new IOException("neighbor majors must not be downloaded for this launch"); + }); + Map service = new LinkedHashMap<>(); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("chrome", service, null)); + String executable = String.valueOf(service.get("driverExecutable")); + assertTrue(executable.contains("131.0.1.1"), executable); + assertFalse(executable.contains("127.0.1.1"), executable); + assertFalse(executable.contains("120.0.1.1"), executable); + + service.clear(); + DriverDownloadFallback.useVersionForTests((browser, binary) -> "132.0.6834.83"); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("chrome", service, null)); + assertFalse(service.containsKey("driverExecutable")); + } + + @Test + void unknownVersionLaunchesStableOnlyAndFillsEightMajorsPerBrowser() throws IOException { + isolatedCache(); + seed("chrome", "130.0.1.1"); + System.setProperty("pkb_driver_download_native", "false"); + System.setProperty("pkb_driver_download_proxy", "http://127.0.0.1:9"); + DriverDownloadFallback.useSynchronousFillForTests(); + DriverDownloadFallback.useVersionForTests((browser, binary) -> null); + List zips = new ArrayList<>(); + DriverDownloadFallback.useTransferForTests(urls -> chromePayload(urls, zips)); + Map service = new LinkedHashMap<>(); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("chrome", service, null)); + String executable = String.valueOf(service.get("driverExecutable")); + assertTrue(executable.contains("131.0.6778.204"), executable); + assertTrue(executable.contains("chromedriver"), executable); + assertFalse(executable.contains("130.0.1.1"), executable); + assertFalse(executable.contains("124.0.6400.0"), executable); + String joined = String.join("\n", zips); + assertTrue(joined.contains("/131.0.6778.204/"), joined); + assertTrue(joined.contains("/129.0.6400.0/"), joined); + assertTrue(joined.contains("/124.0.6400.0/"), joined); + assertFalse(joined.contains("/130.0.6400.0/"), joined); + assertFalse(joined.contains("/123."), joined); + assertFalse(joined.contains("/120."), joined); + assertFalse(joined.contains("edgedriver"), joined); + assertFalse(joined.contains("chromedriver.storage.googleapis.com"), joined); + } + + @Test + void edgeFillDoesNotReuseChromeMajors() throws IOException { + isolatedCache(); + seed("chrome", "120.0.1.1"); + System.setProperty("pkb_driver_download_native", "false"); + System.setProperty("pkb_driver_download_proxy", "http://127.0.0.1:9"); + DriverDownloadFallback.useSynchronousFillForTests(); + DriverDownloadFallback.useVersionForTests((browser, binary) -> null); + List seen = new ArrayList<>(); + DriverDownloadFallback.useTransferForTests(urls -> edgePayload(urls, seen)); + Map service = new LinkedHashMap<>(); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("edge", service, null)); + String executable = String.valueOf(service.get("driverExecutable")); + assertTrue(executable.contains("msedgedriver"), executable); + assertTrue(executable.contains("131.0.2903.51"), executable); + assertFalse(executable.contains("120.0.1.1"), executable); + String joined = String.join("\n", seen); + assertTrue(joined.contains("LATEST_RELEASE_124_"), joined); + assertFalse(joined.contains("LATEST_RELEASE_123_"), joined); + assertFalse(joined.contains("LATEST_RELEASE_131/"), joined); + assertFalse(joined.contains("LATEST_RELEASE_131WINDOWS"), joined); + assertFalse(joined.contains("chromedriver"), joined); + assertFalse(joined.contains("azureedge.net"), joined); + assertTrue(joined.contains("/131.0.2903.51/edgedriver_"), joined); + assertTrue(joined.contains("/124.0.6400.0/edgedriver_"), joined); + assertFalse(joined.contains("/131.0.6400.0/"), joined); + } + + @Test + void untestedWarningDoesNotReplaceTheOpenExecutable() throws IOException { + isolatedCache(); + System.setProperty("pkb_driver_download_native", "false"); + System.setProperty("pkb_driver_download_proxy", "http://127.0.0.1:9"); + DriverDownloadFallback.useSynchronousFillForTests(); + List zips = new ArrayList<>(); + DriverDownloadFallback.useTransferForTests(urls -> { + String url = urls.get(0); + if (url.contains("LATEST_RELEASE_131_")) { + return "131.0.6778.85".getBytes(StandardCharsets.US_ASCII); + } + if (url.contains("LATEST_RELEASE_131")) { + return "131.0.6778.85".getBytes(StandardCharsets.US_ASCII); + } + if (url.contains(".zip")) { + zips.add(url); + return zipWith(url.contains("edgedriver") ? "msedgedriver" : "chromedriver"); + } + throw new IOException("warning fill should not sweep other majors: " + url); + }); + Map service = new LinkedHashMap<>(); + service.put("driverExecutable", "/opt/running/chromedriver"); + String kept = DriverDownloadFallback.keepOpenDriver( + "/opt/running/chromedriver", + "chrome", + "This version of ChromeDriver has not been tested with Chrome version 131."); + assertEquals("/opt/running/chromedriver", kept); + assertEquals("/opt/running/chromedriver", service.get("driverExecutable")); + assertEquals(1, zips.size(), zips.toString()); + assertTrue(zips.get(0).contains("/131.0.6778.85/"), zips.toString()); + assertTrue(Files.isRegularFile(DriverDownloadFallback.driverCacheFile("chrome", "131.0.6778.85"))); + } + + @Test + void openSessionDoesNotDownloadStable() { + AtomicInteger calls = new AtomicInteger(); + DriverDownloadFallback.useTransferForTests(urls -> { + calls.incrementAndGet(); + throw new IOException("session already started"); + }); + assertNull(DriverDownloadFallback.recoverExecutable( + "chrome", + new SessionNotCreatedException("Current browser version is 131.0.6778.85"), + null, + true)); + assertEquals(0, calls.get()); + } + + @Test + void versionedZipFailureRetriesTheOtherHosts() throws IOException { + isolatedCache(); + AtomicReference> seen = new AtomicReference<>(); + DriverDownloadFallback.useTransferForTests(urls -> { + if (urls.get(0).contains(".zip")) { + seen.set(List.copyOf(urls)); + return zipWith("chromedriver"); + } + throw new IOException("metadata was not requested"); + }); + String path = DriverDownloadFallback.recoverExecutable( + "chrome", + new RuntimeException("error downloading https://storage.googleapis.com/chrome-for-testing-public/" + + "131.0.6778.204/linux64/chromedriver-linux64.zip"), + null, + false); + assertNotNull(path); + assertTrue(path.contains("131.0.6778.204"), path); + List urls = seen.get(); + assertEquals(List.of( + "https://commondatastorage.googleapis.com/chrome-for-testing-public/131.0.6778.204/" + + platformOf(urls.get(0)) + "/chromedriver-" + platformOf(urls.get(0)) + ".zip", + "https://storage.googleapis.com/chrome-for-testing-public/131.0.6778.204/" + + platformOf(urls.get(0)) + "/chromedriver-" + platformOf(urls.get(0)) + ".zip", + "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/131.0.6778.204/" + + platformOf(urls.get(0)) + "/chromedriver-" + platformOf(urls.get(0)) + ".zip" + ), urls); + assertFalse(String.join("\n", urls).contains("chromedriver.storage.googleapis.com")); + } + + @Test + void sessionMismatchIsRetriedOnlyOnce() { + SessionNotCreatedException mismatch = new SessionNotCreatedException( + "Current browser version is 132.0.6834.83"); + assertTrue(DriverDownloadFallback.allowsAnotherSessionRetry(false, mismatch)); + assertFalse(DriverDownloadFallback.allowsAnotherSessionRetry(true, mismatch)); + assertFalse(DriverDownloadFallback.allowsAnotherSessionRetry(false, new RuntimeException("timed out"))); + } + + @Test + void missingProxyToolsAreSkipped() throws IOException { + isolatedCache(); + assertNull(DriverDownloadFallback.toolOutput("pkb-missing-scutil")); + assertNull(DriverDownloadFallback.toolOutput("pkb-missing-netsh")); + assertNull(DriverDownloadFallback.toolOutput("pkb-missing-powershell")); + assertDoesNotThrow(DriverDownloadFallback::optionalOsProxies); + System.setProperty("pkb_driver_download_native", "false"); + System.setProperty("pkb_driver_download_proxy", "http://user:s3cret@127.0.0.1:9"); + DriverDownloadFallback.useVersionForTests((browser, binary) -> "120.0.6099.109"); + DriverDownloadFallback.useTransferForTests(urls -> { + throw new IOException("powershell was not found; DefaultNetworkCredentials were not sent"); + }); + Map service = new LinkedHashMap<>(); + assertDoesNotThrow(() -> DriverDownloadFallback.prepareLaunch("chrome", service, null)); + assertFalse(service.containsKey("driverExecutable")); + assertNull(System.getProperty("http.proxyHost")); + assertNull(System.getProperty("https.proxyHost")); + } + + private static Path isolatedCache() throws IOException { + Path cache = Files.createTempDirectory("pkb-driver-cache"); + System.setProperty("SE_CACHE_PATH", cache.toString()); + return cache; + } + + private static void seed(String browser, String version) throws IOException { + Path binary = DriverDownloadFallback.driverCacheFile(browser, version); + Files.createDirectories(binary.getParent()); + Files.write(binary, new byte[]{1, 2, 3}); + } + + private static byte[] chromePayload(List urls, List zips) throws IOException { + String url = urls.get(0); + if (url.contains("LATEST_RELEASE_STABLE")) { + return "131.0.6778.204\n".getBytes(StandardCharsets.US_ASCII); + } + if (url.contains("latest-versions-per-milestone")) { + return milestoneJson(131, 120); + } + if (url.contains(".zip")) { + zips.add(url); + return zipWith("chromedriver"); + } + throw new IOException("unexpected chrome url " + url); + } + + private static byte[] edgePayload(List urls, List seen) throws IOException { + String url = urls.get(0); + seen.add(url); + if (url.contains("LATEST_STABLE")) { + return "131.0.2903.51".getBytes(StandardCharsets.US_ASCII); + } + if (url.contains("LATEST_RELEASE_")) { + int marker = url.indexOf("LATEST_RELEASE_") + "LATEST_RELEASE_".length(); + int end = marker; + while (end < url.length() && Character.isDigit(url.charAt(end))) { + end++; + } + return (url.substring(marker, end) + ".0.6400.0").getBytes(StandardCharsets.US_ASCII); + } + if (url.contains(".zip")) { + return zipWith("msedgedriver"); + } + throw new IOException("unexpected edge url " + url); + } + + private static byte[] milestoneJson(int newest, int oldest) { + StringBuilder json = new StringBuilder("{\"milestones\":{"); + for (int major = oldest; major <= newest; major++) { + if (major != oldest) { + json.append(','); + } + json.append('"').append(major).append("\":{\"version\":\"") + .append(major).append(".0.6400.0\"}"); + } + json.append("}}"); + return json.toString().getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] zipWith(String binaryName) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("driver/" + binaryName)); + zip.write(new byte[]{1, 2, 3, 4}); + zip.closeEntry(); + } + return out.toByteArray(); + } + + private static String platformOf(String url) { + String marker = "/131.0.6778.204/"; + int start = url.indexOf(marker) + marker.length(); + int end = url.indexOf('/', start); + return url.substring(start, end); + } +} diff --git a/src/test/java/tools/dscode/testengine/DriverDownloadRedactionTest.java b/src/test/java/tools/dscode/testengine/DriverDownloadRedactionTest.java new file mode 100644 index 000000000..cd71bc940 --- /dev/null +++ b/src/test/java/tools/dscode/testengine/DriverDownloadRedactionTest.java @@ -0,0 +1,32 @@ +package tools.dscode.testengine; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DriverDownloadRedactionTest { + + @Test + void proxyUrlIsRedactedLikeAReportPortalPassword() { + String secret = "http://user:s3cret@proxy.example:8080"; + assertTrue(SensitiveConfiguration.isSensitive(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY)); + assertEquals(SensitiveConfiguration.REDACTED, + SensitiveConfiguration.displayValue(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY, secret)); + assertEquals(SensitiveConfiguration.REDACTED, + SensitiveConfiguration.displayValue("pkb_rp_http_proxy_password", "s3cret")); + + Map values = new LinkedHashMap<>(); + values.put(PKB_props.PKB_DRIVER_DOWNLOAD_PROXY, secret); + values.put(PKB_props.PKB_BROWSER, "CHROME_HEADLESS"); + String profile = PickleballProfiles.serializeRunProfile(values); + assertFalse(profile.contains("s3cret")); + assertFalse(profile.contains("proxy.example")); + assertTrue(profile.contains("${protected:pkb_driver_download_proxy}")); + assertTrue(profile.contains("pkb_browser=CHROME_HEADLESS")); + } +} From a99a8bfe8353f9b2ee07dbefa1c006b9cff77308 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Tue, 6 Oct 2026 01:06:28 +0000 Subject: [PATCH 22/23] Bottom line: investigations and the board stay at the .pickleball root, a note to any is a post, and a browser profile is deleted only by gc-runs after the run has stopped. --- AGENTS.md | 2 +- docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 30 + docs/ai-run-configuration.md | 2 +- docs/configuration.md | 12 +- docs/consumer-agent-guide.md | 47 +- docs/consumer-project.md | 2 +- docs/diagnostic-reporting.md | 4 +- docs/pickleball-workbench.md | 2 +- .../.github/copilot-instructions.md | 2 +- maven-consumer-project/AGENTS.md | 2 +- .../protocol/InvestigationHandoff.java | 82 +++ .../protocol/PickleballLocalLayout.java | 13 +- pickleball-workbench/AGENTS.md | 10 +- .../workbench/mcp/WorkbenchMcpTools.java | 2 +- .../dscode/workbench/ui/WorkbenchFrame.java | 19 +- scripts/refresh_agent_index.py | 40 ++ scripts/verify_agent_contract.py | 22 + .../coordination/AgentCoordination.java | 646 +++++++++++++++++- .../launcher/WorkbenchAgentCommands.java | 153 +++++ .../dscode/launcher/WorkbenchCommandLine.java | 65 +- .../pickleball/guidance/AGENT-GUIDE.md | 47 +- .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 30 + .../guidance/docs/ai-run-configuration.md | 2 +- .../pickleball/guidance/docs/configuration.md | 12 +- .../guidance/docs/consumer-agent-guide.md | 47 +- .../guidance/docs/consumer-project.md | 2 +- .../guidance/docs/diagnostic-reporting.md | 4 +- .../guidance/docs/pickleball-workbench.md | 2 +- .../coordination/AgentCoordinationTest.java | 211 +++++- .../protocol/PickleballLocalLayoutTest.java | 39 +- .../protocol/PickleballLocalStoreTest.java | 35 + .../launcher/WorkbenchAgentCommandsTest.java | 28 + .../launcher/WorkbenchCommandLineTest.java | 4 + 35 files changed, 1527 insertions(+), 97 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fe82f6fa..e1d733f56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,7 @@ Only the separate consumer worker JVM executes Pickleball. `WorkbenchWorkerManag The published Pickleball JAR embeds the completed controller-only Workbench JAR as exactly one opaque payload at `META-INF/pickleball/workbench/pickleball-workbench.jar`. It must not flatten Workbench/MCP classes into the outer runtime, and the nested Workbench must not contain core. The ownership mnemonic is: **Pickleball may contain Workbench; Workbench must not contain Pickleball.** -The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +The Swing window is a client of the launcher and command-queue session commands (`open-scenario`, `example`, `play`, `from-here`, `execute-step`, `stop`, `diagnostic-run`, and the other `WorkbenchSessionActions` operations). Do not add a second GUI-only implementation, and do not add a pixel, WebView, or JavaFX click tool. When a Workbench window is already open, an agent drives that session with those commands so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. When nobody is watching, headless `discover`, `confirm`, and `isolate` stay the path. Build invocation uses the consumer project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not embed Maven or Gradle in MCP or the launcher, and do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. The Workbench GUI is only a lightweight head over state and controls that already exist under the hood. Agents have direct access to that state and those controls, and direct control of the GUI controls, so they can collaborate with a person or present data and reports in the window. The GUI must not own behavior the agents cannot reach. Do not click JavaFX or WebView from tests. Do not embed Maven or Gradle. Agents run the project wrapper. A private `.pickleball/runs//` directory is only for an agent or Workbench run. A normal `mvn test` does not create it, does not copy configs, does not set `user-data-dir`, and still writes both HTML reports and still reads the project configs. A private run copies the project configs into `config/` once and reads and writes only that copy. The Config tab beside Mapping is a view of that copy. Sync, run start, and other long loads stay off the event thread and show a banner that names the work or the error. Split panes drag so one panel can take almost the whole window. Parallel workers in one private run each get `browser-profile/`. `pkb_compositeReport=false` and `pkb_scenarioReport=false` are the agent and Workbench defaults. Do not change the `pkb_parallel` default. Isolate stays one scenario. Do not treat resolve-runvars as Discover's environment. Trust the post-Discover run record. Sealed input is `pkb_overriderunvars` only. diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index a22181563..407ae37c7 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `ConfigTabModel`; `LongWork`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `ConfigTabModelTest`; `LongWorkTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `.pickleball/investigations/`; `.pickleball/presence/`; `.pickleball/posts/`; `.pickleball/history.log`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 29f585d5c..85ca72f28 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -818,3 +818,33 @@ This inventory helps coding agents discover relevant files. It does not replace - `scripts/refresh_agent_index.py` - `scripts/sync_consumer_guidance.py` - `scripts/verify_agent_contract.py` + +## .pickleball owners + +Jar cache is regenerable under `v//`, `open/`, and `current.json`. +Project history stays at the `.pickleball` root and survives export-guidance. +Legacy `.pickleball/workbench/` is only the fallback when `current.json` is missing or not usable. + +- `.pickleball/v//` +- `.pickleball/open/` +- `.pickleball/current.json` +- `.pickleball/runs//` +- `.pickleball/agent-log` +- `.pickleball/inbox//` +- `.pickleball/investigations//` +- `.pickleball/presence/.json` +- `.pickleball/posts/.json` +- `.pickleball/history.log` + +Launcher verbs, same exec.args style as inbox, note, and short-log: + +- `presence --touch` +- `presence --list` +- `presence --sweep` +- `post --write` +- `post --list` +- `post --renew` +- `post --sweep` +- `history --append` +- `history --tail` +- `gc-runs` diff --git a/docs/ai-run-configuration.md b/docs/ai-run-configuration.md index 407bd0f61..a417a03aa 100644 --- a/docs/ai-run-configuration.md +++ b/docs/ai-run-configuration.md @@ -432,7 +432,7 @@ When operating in a consumer project: The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. A blocked local driver download does not fail the run: Selenium Manager is tried first unless `pkb_driver_download_native=false`, and the fallback is skipped only when `pkb_driver_download_proxy=false`. An explicit `driver.service.driverExecutable` skips both. The proxy value is redacted. Do not invent a `configs` proxy block and do not copy it onto `HTTPS_PROXY` or `SE_PROXY`. See [Execution Configuration](configuration.md). -Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. ```text pkb_browser= diff --git a/docs/configuration.md b/docs/configuration.md index 8807a1294..6b2151356 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -122,13 +122,17 @@ Reports, diagnostic packs, scratch, and a headless Workbench session file for th The run record is `.pickleball/runs//record.json`: run id, agent id, optional group, sequence, who, why, what was learned, start time, stop time, status, and the path to the run data. The short log only points at that record. -The short log is `.pickleball/agent-log`. Read it before a run, after a run, and during a long session, instead of the dense diagnostic log. Each line is a timestamp, agent id, run id, `start` or `stop` or `note`, a one-line purpose, and the path to the run record. Appending does not take a file lock. A dead agent must not block the others. On each write, lines older than 3 days are dropped. Newer lines stay. +The short log is `.pickleball/agent-log`. Read it before a run, after a run, and during a long session, instead of the dense diagnostic log. Each line is a timestamp, agent id, run id, `start` or `stop` or `note`, a one-line purpose, and the path to the run record. Appending does not take a file lock. A dead agent must not block the others. On each write, lines older than 3 days are dropped. Newer lines stay. A dropped run whose directory remains keeps a one-line stub so the index does not vanish. Do not put fix history in the short log. -`inbox//` is a note for that agent. `inbox/any/` is a note any active agent may take. A note is one short file: who wrote it, timestamp, one message, optional path to a run record. The reader deletes the note after taking it. The inbox is not a second log and not an assignment framework. +`inbox//` is directed mail for that agent. `take` deletes that note. `list` does not. `inbox/any` is not first-taker-wins. A note to every agent is a post, not a consumed inbox file. The inbox is not a second log and not an assignment framework. -When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, stop a headless run, or erase `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. +The board is three files, not more inbox folders. `.pickleball/presence/.json` is a heartbeat (`agent id`, `lastSeen`, current run ids). An active agent may drop a row older than 15 minutes. That drop removes the row only. `.pickleball/posts/.json` is a hint (`id`, `from`, `to` an agent id or `all`, optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, optional `ackedBy`). Default expiry is 24 hours from the last update. One hour is appropriate for "I have the window" and is not the default. List does not delete. Ack is optional. `.pickleball/history.log` is an append-only summation (version, files, fix, branch, commit, merge, run id), trimmed from the front above 10 MB. It is not a mailbox. Agents are not daemons. Launcher commands are one-shot: `presence --touch|--list|--sweep`, `post --write|--list|--renew|--sweep`, `history --append|--tail`, and `gc-runs`. -Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. +`browser-profile/` under a run is deleted only by `gc-runs`, and only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Remote drivers and a consumer-supplied `user-data-dir` are not this directory. Dense evidence stays for the run open in Workbench and for the last failed run. Other stopped payloads may go 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile`. Never delete a run with no stop line. `finish` and `stop` only write `stoppedAt` and a stop line. + +When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, the profile, the session file, or `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. + +Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, `presence`, `post`, `history`, `gc-runs`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. They do not delete the run. ## Named profiles diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index 5bc668513..a9e59ff37 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -20,7 +20,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. @@ -37,9 +37,46 @@ Do not copy consumer features into `.pickleball` as a sandbox. ### Run coordination -Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder, a live session, or a config copy. Each private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. + +One window has one driver. Each agent uses its own run id. Launcher commands are one-shot and exit. A 10-minute presence sweep is optional and cannot be enforced. + +### Board + +Three files. Do not merge them into the inbox. + +1. Presence. `.pickleball/presence/.json` has the agent id, `lastSeen`, and current run ids. Update it when you want: `presence --touch --agent= [--run-id=]`, `presence --list`, `presence --sweep`. An active agent may drop a row whose `lastSeen` is older than 15 minutes. That drop removes the row only. It does not delete posts, runs, profiles, or `history.log`. A missing heartbeat means not on the board, not clear their work. Two sweepers are idempotent. +2. Posts. `.pickleball/posts/.json` has id, from, to (an agent id or `all`), optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, and optional `ackedBy`. `post --write --to= --text= [--run-id] [--ttl=]`, `post --list`, `post --renew --id=`, `post --sweep`. Every agent may list them. Do not delete on read. Ack is optional and is not required to clear. Default expiry is 24 hours from the last update, not from the first write. The author may set a shorter expiry. One hour is appropriate for "I have the window." Short expiry is not the default. Renew by updating `updatedAt` and `expiresAt`. A missing or unreadable timestamp counts as expired. After expiry, any active agent may delete that post file. Two deletes of the same file are fine. Deleting a post does not delete the run, the profile, the presence row, or `history.log`. A post is a hint, not a lock. Readers treat it as possibly stale even if it is new. +3. Summation log. `.pickleball/history.log` is append-only. One short line: version, files, fix, branch, commit, merge, run id. `history --append --text=` and `history --tail`. A missing run directory is normal. Trim from the front when the file exceeds 10 MB. Not a mailbox. Do not put keep-alives here. + +The short log `.pickleball/agent-log` stays the 3-day coordination tape. Appending does not take a file lock. On prune, a dropped run whose directory remains keeps a one-line stub so the index does not vanish. Do not put fix history in the short log. Do not add a lock a crashed agent can hold. + +### .pickleball has two owners + +Do not collapse `.pickleball` under `v/`. + +Jar cache, regenerable from the resolved dependency: + +- `.pickleball/v//` guidance, docs, the maven-consumer-project snapshot, `workbench/controller/`, and `workbench/lib/` +- `.pickleball/open/` +- root aliases `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` +- `current.json` written last: `pickleballVersion`, `complete`, `updatedAt` + +Project history. It must survive `export-guidance` and a version bump. It stays at the `.pickleball` root: + +- `runs//` +- `agent-log` +- `inbox/` +- `investigations/` +- `presence/`, `posts/`, `history.log` + +`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. New session, attach, last-discover, logs, and `live/classes` stay under `v//workbench` when `current.json` is complete. Do not describe both layouts as current. + +Export cleanup is a manifest diff of generated files in that version folder. There is no general `.pickleball` garbage collector. Do not delete other `v//` trees until the new `current.json` is complete and a launch has succeeded. Keeping the previous version tree is enough. Never automatically delete `current.json`, `open/`, or the `v//` tree named by a complete pointer, or an in-progress export (`complete: false`). + +New investigation handoffs always go to `.pickleball/investigations//`. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read the legacy versioned path. `export-guidance` leaves both trees alone. ### Live isolation loop @@ -58,7 +95,7 @@ After Discover has found the failing scenario, use the session that is already o - `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. - `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. -- `.pickleball/investigations/` is unmanaged consumer-agent output. `export-guidance` leaves it alone. +- `.pickleball/investigations/` is project history, not generated guidance. New handoffs always go there. A legacy `v//investigations/` tree is still readable when the root path for that id is absent. `export-guidance` leaves both trees alone. - `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. ## First-read @@ -90,7 +127,7 @@ A successful `export-guidance .pickleball` run: Any Pickleball execution (`PickleballRunner`, `java -jar pickleball.jar`, Workbench launcher) lazily materializes the running jar's version folder if it is missing. It unpacks from the running jar; it never copies a sibling version folder. Direct `java -jar pickleball-X.jar` pins `current.json` to X. Open scripts set `PKB_OPEN_BOOTSTRAP=1` so Java hops to the pinned jar when `current.json` is complete. -The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat any existing `.pickleball` contents as potentially stale. `complete: false` or a missing `current.json` means do not trust the tree. The manifest records the last completed export; it is not a substitute for rerunning the exporter. +The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. The manifest records the last completed export; it is not a substitute for rerunning the exporter. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Version folders keep each export isolated. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. diff --git a/docs/consumer-project.md b/docs/consumer-project.md index 65530dc4d..10ad41712 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot diff --git a/docs/diagnostic-reporting.md b/docs/diagnostic-reporting.md index c80d2eb5f..b0926184c 100644 --- a/docs/diagnostic-reporting.md +++ b/docs/diagnostic-reporting.md @@ -143,7 +143,7 @@ Prefer `DiagnosticCli` over custom Maven-classpath/JShell workflows for routine report.html # one-page local render ``` -When `.pickleball/current.json` is complete, new handoffs go under `.pickleball/v//investigations/` instead. Legacy `.pickleball/investigations/` remains when no current pointer exists. +New handoffs always go to `.pickleball/investigations//`, even when `.pickleball/current.json` is complete. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read that legacy versioned path. Input is investigation JSON from a file or stdin (`-`) plus the consumer project root. The command prints the project-relative `report.html` path. JSON is the source of truth. HTML renders that JSON plus at most two screenshots *linked* from the existing diagnostic pack; extra screenshot paths are ignored, and a missing image becomes a short note rather than a failed emit. The writer does not copy `reports/diagnostic-runs/` and does not change `pkb_diagnostic_output`. Do not embed PNG bytes in investigation JSON. Headless Workbench MCP exposes the same emit as `workbench_investigation_emit` and returns only that relative report path. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md`, then that path. Portable `report.html` is an indented list in Gherkin/business-first order. Do not use Mermaid. @@ -183,7 +183,7 @@ links[] # optional workbench_go / wb:// targets Do not parse Gherkin in the Workbench controller to invent `executionMap`. If the agent omitted it, derive it from `events.jsonl` at emit. v1 JSON still renders. Workbench Report is the same JSON. -`export-guidance` does not manage or delete `.pickleball/investigations/` or versioned `v//investigations/`. +`export-guidance` does not manage or delete `.pickleball/investigations/` or any legacy `v//investigations/` tree. ## Outcomes and completion diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index 7771f140e..b15f9af06 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -75,7 +75,7 @@ Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `g "-Dexec.args=finish --run-id= --learned='the empty row fails'" ``` -Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. +Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, only one agent drives it and `isolate` does not start a second session. One window has one driver. Agents are not daemons, and each agent uses its own run id. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. Presence, posts, and `history.log` stay separate from the inbox. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. `finish` and `stop` do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. New session, attach, last-discover, logs, and `live/classes` live under `v//workbench` when `current.json` is complete. `.pickleball/workbench/` is only the fallback when that pointer is missing or not usable. `hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. diff --git a/maven-consumer-project/.github/copilot-instructions.md b/maven-consumer-project/.github/copilot-instructions.md index 527dcd03e..172ebe3c3 100644 --- a/maven-consumer-project/.github/copilot-instructions.md +++ b/maven-consumer-project/.github/copilot-instructions.md @@ -4,6 +4,6 @@ Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `- Open a run with `open-window --run-id` or `show-run --run-id`, and close it with `close-window`. Opening loads that run and does not start a test. When the window is already open, drive the live run with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. -Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. New investigations go to `.pickleball/investigations/`. Gradle projects use `gradlew.bat` or `gradlew` the same way. Pick the script from the OS. diff --git a/maven-consumer-project/AGENTS.md b/maven-consumer-project/AGENTS.md index 527dcd03e..172ebe3c3 100644 --- a/maven-consumer-project/AGENTS.md +++ b/maven-consumer-project/AGENTS.md @@ -4,6 +4,6 @@ Use Workbench `hint`, `discover`, and `confirm` (narrow `--tags` / `--name` / `- Open a run with `open-window --run-id` or `show-run --run-id`, and close it with `close-window`. Opening loads that run and does not start a test. When the window is already open, drive the live run with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees it. When nobody is watching, `isolate` starts a headless session, then `execute-step`, `status`, `events`, and `stop`. -Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session. Use your own run id. When a Workbench window is already open, only one agent drives it; other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. New investigations go to `.pickleball/investigations/`. Gradle projects use `gradlew.bat` or `gradlew` the same way. Pick the script from the OS. diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java index 02a89a045..6fcc5547b 100644 --- a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/InvestigationHandoff.java @@ -292,6 +292,48 @@ public static Path investigationsRoot(Path pickleballDirectory) { return pickleballDirectory.resolve(INVESTIGATIONS_DIRECTORY); } + /** + * Directory for an existing handoff. New writes use + * {@link PickleballLocalLayout#investigationsDirectory(Path)}. If that root + * path is absent, the legacy {@code v//investigations/} tree + * is returned when it exists. + */ + public static Path directoryFor(Path projectRoot, String investigationId) { + String id = requireInvestigationId(investigationId); + Path primary = PickleballLocalLayout.investigationsDirectory(projectRoot).resolve(id); + if (Files.exists(primary)) return primary; + Path legacy = legacyDirectory(projectRoot, id); + return legacy == null ? primary : legacy; + } + + public static java.util.Optional readJson(Path projectRoot, String investigationId) throws IOException { + Path json = directoryFor(projectRoot, investigationId).resolve("investigation.json"); + if (!Files.isRegularFile(json)) return java.util.Optional.empty(); + return java.util.Optional.of(Files.readString(json, StandardCharsets.UTF_8)); + } + + /** + * Root reports first. A legacy versioned id is included only when the root + * path for that id is absent. + */ + public static List reportFiles(Path projectRoot) throws IOException { + Map byId = new LinkedHashMap<>(); + collectReports(byId, PickleballLocalLayout.investigationsDirectory(projectRoot)); + Path versions = PickleballLocalLayout.root(projectRoot).resolve(PickleballLocalLayout.VERSIONS_DIRECTORY); + if (Files.isDirectory(versions)) { + try (var children = Files.list(versions)) { + for (Path version : children.filter(Files::isDirectory).sorted().toList()) { + collectReports(byId, version.resolve(INVESTIGATIONS_DIRECTORY), true); + } + } + } + return List.copyOf(byId.values()); + } + + /** + * True for the root investigations tree and for any legacy + * {@code v//investigations} tree. Export cleanup must leave both alone. + */ public static boolean isInvestigationsPath(Path pickleballDirectory, Path path) { if (pickleballDirectory == null || path == null) return false; Path investigations = investigationsRoot(pickleballDirectory).toAbsolutePath().normalize(); @@ -305,6 +347,46 @@ public static boolean isInvestigationsPath(Path pickleballDirectory, Path path) || resolved.toString().replace('\\', '/').contains("/" + INVESTIGATIONS_DIRECTORY + "/")); } + private static Path legacyDirectory(Path projectRoot, String id) { + Path versions = PickleballLocalLayout.root(projectRoot).resolve(PickleballLocalLayout.VERSIONS_DIRECTORY); + if (!Files.isDirectory(versions)) return null; + Path preferred = null; + var current = PickleballLocalLayout.readCurrent(PickleballLocalLayout.root(projectRoot)); + if (current.isPresent() && current.get().usable()) { + Path candidate = PickleballLocalLayout.versionRoot( + PickleballLocalLayout.root(projectRoot), + current.get().pickleballVersion() + ).resolve(INVESTIGATIONS_DIRECTORY).resolve(id); + if (Files.isDirectory(candidate)) preferred = candidate; + } + if (preferred != null) return preferred; + try (var children = Files.list(versions)) { + for (Path version : children.filter(Files::isDirectory).sorted().toList()) { + Path candidate = version.resolve(INVESTIGATIONS_DIRECTORY).resolve(id); + if (Files.isDirectory(candidate)) return candidate; + } + } catch (IOException ignored) { + return null; + } + return null; + } + + private static void collectReports(Map byId, Path investigations) throws IOException { + collectReports(byId, investigations, false); + } + + private static void collectReports(Map byId, Path investigations, boolean onlyIfAbsent) throws IOException { + if (!Files.isDirectory(investigations)) return; + try (var directories = Files.list(investigations)) { + for (Path directory : directories.filter(Files::isDirectory).sorted().toList()) { + String id = directory.getFileName().toString(); + if (onlyIfAbsent && byId.containsKey(id)) continue; + Path html = directory.resolve("report.html"); + if (Files.isRegularFile(html)) byId.putIfAbsent(id, html); + } + } + } + private static Path requireProjectRoot(Path projectRoot) { if (projectRoot == null) { throw new IllegalArgumentException("Consumer project root is required."); diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java index 680b5a439..546baeb13 100644 --- a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java @@ -142,14 +142,13 @@ public static Path attachFile(Path projectRoot) { return workbenchStateRoot(projectRoot).resolve(ATTACH_FILE); } + /** + * New investigation handoffs always go here: {@code .pickleball/investigations}. + * A complete {@code current.json} does not move them under {@code v//}. + * Existing versioned trees are legacy reads only. + */ public static Path investigationsDirectory(Path projectRoot) { - Path pickleball = root(projectRoot); - Optional current = readCurrent(pickleball); - if (current.isPresent() && current.get().usable()) { - return versionRoot(pickleball, current.get().pickleballVersion()) - .resolve(InvestigationHandoff.INVESTIGATIONS_DIRECTORY); - } - return pickleball.resolve(InvestigationHandoff.INVESTIGATIONS_DIRECTORY); + return root(projectRoot).resolve(InvestigationHandoff.INVESTIGATIONS_DIRECTORY); } public static Optional readCurrent(Path pickleballRoot) { diff --git a/pickleball-workbench/AGENTS.md b/pickleball-workbench/AGENTS.md index af2f39c8d..837a355e9 100644 --- a/pickleball-workbench/AGENTS.md +++ b/pickleball-workbench/AGENTS.md @@ -43,7 +43,7 @@ PKB_CONTROL_BRIDGE_PAUSE_FIRST_SCENARIO Pickleball may accept the old `PKB_STUDIO_BRIDGE_*` names as deprecated compatibility input aliases. Workbench code must emit only the neutral `PKB_CONTROL_BRIDGE_*` names. The aliases must never require Studio code or dependencies. -Project-local `.pickleball/workbench/` content is disposable state and must not be treated as source. +Project-local Workbench state is `.pickleball/v//workbench` when `current.json` is complete: session, attach, last-discover, logs, and `live/classes`. `.pickleball/workbench/` is only the fallback when `current.json` is missing or not usable. Do not describe both layouts as current, and do not treat either as source. Investigations stay at `.pickleball/investigations/`, not under `v/`. ## Swing UI @@ -113,11 +113,11 @@ Workbench provides: java -cp pickleball-workbench-.jar: tools.dscode.workbench.WorkbenchApplication mcp ``` -The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the front door. When a Workbench window is already open, drive that session with the same commands the window calls so the person sees the result. When nobody is watching, launcher `isolate` / `execute-step` stay on the headless CLI session. Hosts may already wire `mcp .` as optional alias. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Do not embed Maven or Gradle in MCP or the launcher. If a tool needs a build, it invokes the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Workbench does not own that board; the consumer launcher does. A per-run `--session-file` is only a path plus `--run-id` / `--agent-id` strings copied onto the worker. +The MCP adapter is `tools.dscode.workbench.mcp.WorkbenchMcpServer` plus `WorkbenchMcpTools`. It exposes project synchronization/status, interactive worker lifecycle, live Gherkin, read-only step resolution (`workbench_step_resolve`), Mapping operations, events/evidence, browser/service controls, semantic breakpoints, Step Override authoring, the watched-agent control lease, player-state inspection, gated Save, sparse diagnostic catalog/run/summary readers, `workbench_investigation_emit`, and one navigation tool `workbench_go` through `WorkbenchServices`. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md` (Gherkin/business first), not a dump. `workbench_go` requires UI attach plus a control lease to move the window; headless it validates and echoes the resolved target. It does not write files. Do not add generic IDE / git / process MCP tools. Do not `git checkout` or reset the consumer HEAD. Do not copy `reports/diagnostic-runs/` into `.pickleball/investigations`. Do not embed PNG bytes in investigation JSON. Do not create a Git repo under `.pickleball`. File copies and filtered patches are written by Pickleball core diagnostic reporting (worker); Workbench only reads the pack. Consumer agents use Workbench `discover` / `confirm` as the front door. When a Workbench window is already open, drive that session with the same commands the window calls so the person sees the result. When nobody is watching, launcher `isolate` / `execute-step` stay on the headless CLI session. Hosts may already wire `mcp .` as optional alias. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. Do not embed Maven or Gradle in MCP or the launcher. If a tool needs a build, it invokes the project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. Do not add a general `mvn` or `gradle` tool. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. Workbench does not own that board; the consumer launcher does. A per-run `--session-file` is only a path plus `--run-id` / `--agent-id` strings copied onto the worker. -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. +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 `attach.json` beside other Workbench state (`v//workbench/attach.json` when `current.json` is complete, otherwise `.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. +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 the Workbench `logs/` directory under `v//workbench` when `current.json` is complete, otherwise `.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. MCP tool failures are represented as MCP tool results with `isError=true`; they must not escape as arbitrary stdout text. Keep protocol tests covering initialize, tool listing, representative controller calls, invalid requests, Step Override compile invocation, protocol-only output, and cleanup. @@ -127,7 +127,7 @@ Do not add generic IDE/file/build/process/collaboration tools to this MCP surfac Workbench synchronization is build-tool-assisted, not a replacement build system. Use the selected project wrapper (`mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar) to establish compiled main/test output, processed resources, and the effective test runtime classpath. Do not fall back to a machine-wide Maven or Gradle install. Gradle synchronization must use build-native init-script/task injection rather than the Gradle Tooling API. Compare **input** fingerprints (Java sources, resources, build files, dependency artifact bytes) to the last manifest before invoking the wrapper: skip when nothing that requires recompilation changed; run resource processing only when only feature/config/data changed; run full `test-compile` / `testClasses` when Java, the build descriptor, or dependencies changed. The output fingerprint in `manifest.json` is provenance, not the skip key. Always pass `-DskipTests`. Live Gherkin buffer edits must never require sync. If compiled project outputs are missing after a clean, escalate resources-only to full compile. -`.pickleball/workbench/base/classes` is synchronization provenance/reset state and must never be on a worker runtime classpath. `.pickleball/workbench/live/classes` is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. Do not treat `live/classes` as an editor. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. +`v//workbench/base/classes` (legacy `.pickleball/workbench/base/classes` only when `current.json` is missing or not usable) is synchronization provenance/reset state and must never be on a worker runtime classpath. `live/classes` under that same Workbench state root is the one merged project-owned runtime root; main output is materialized first and test output overlays it so one class/resource path is visible exactly once. Do not treat `live/classes` as an editor. External dependency entries stay referenced from their normal caches. The synchronization fingerprint covers both merged project output and dependency artifact contents, so replacing a same-version local dependency still changes the snapshot identity. The controller owns one interactive worker per selected project by default. Workers launch directly with Java from the existing Workbench snapshot, use the Pickleball-side worker class-name contract, and set the protocol-owned `pickleball.workbench.testOutputRoot` property so core intentionally scans the merged live root instead of relying on Maven/Gradle output suffixes. The worker PID must differ from the controller PID; its reported Pickleball code source must be exactly one captured consumer classpath entry; its version must match the synchronized manifest; and its classpath must exclude the Workbench controller artifact. Incompatible protocol/capability/origin checks fail clearly and never fall back to a bundled runtime. diff --git a/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java b/pickleball-workbench/src/main/java/tools/dscode/workbench/mcp/WorkbenchMcpTools.java index 59ba3bc32..132eac4d7 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 @@ -217,7 +217,7 @@ private void register() { ), "runId", "scenarioId"), args -> services.diagnosticScenarioSummary(text(args, "runId"), text(args, "scenarioId"))); add("workbench_investigation_emit", - "Write .pickleball/investigations//{investigation.json,report.html} from investigation JSON (versioned under v//investigations when current.json is complete). Returns the relative report.html path only. Does not copy the diagnostic pack or embed PNG bytes.", + "Write .pickleball/investigations//{investigation.json,report.html}. New handoffs always use that root path. A legacy v//investigations/ tree is still readable when the root path is absent. Returns the relative report.html path only. Does not copy the diagnostic pack or embed PNG bytes.", schema(Map.of( "investigation", Map.of( "type", "object", 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 205730f55..81f235995 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,7 +5,6 @@ import tools.dscode.control.protocol.ControlBridgeEvent; import tools.dscode.control.protocol.ControlBridgeEventPage; import tools.dscode.control.protocol.ControlBridgeMappingSnapshot; -import tools.dscode.control.protocol.PickleballLocalLayout; import tools.dscode.control.protocol.PickleballVersion; import tools.dscode.control.protocol.RunView; import tools.dscode.control.protocol.WindowDriver; @@ -767,19 +766,13 @@ private JPanel reportPanel() { private void refreshReport() { reportFiles.clear(); reportPicker.removeAllItems(); - Path investigations = PickleballLocalLayout.investigationsDirectory(controller.projectRoot()); - if (Files.isDirectory(investigations)) { - try (var directories = Files.list(investigations)) { - directories.filter(Files::isDirectory).sorted().forEach(directory -> { - Path html = directory.resolve("report.html"); - if (Files.isRegularFile(html)) { - String id = directory.getFileName().toString(); - reportFiles.put(id, html); - reportPicker.addItem(id); - } - }); - } catch (Exception ignored) { + try { + for (Path html : tools.dscode.control.protocol.InvestigationHandoff.reportFiles(controller.projectRoot())) { + String id = html.getParent() == null ? html.getFileName().toString() : html.getParent().getFileName().toString(); + reportFiles.put(id, html); + reportPicker.addItem(id); } + } catch (Exception ignored) { } if (reportFiles.isEmpty()) { reportView.setText("

No investigation reports under .pickleball/investigations.

"); diff --git a/scripts/refresh_agent_index.py b/scripts/refresh_agent_index.py index 0f6502796..da1fdf7eb 100644 --- a/scripts/refresh_agent_index.py +++ b/scripts/refresh_agent_index.py @@ -115,6 +115,31 @@ )), ) +BOARD_PATHS = ( + ".pickleball/v//", + ".pickleball/open/", + ".pickleball/current.json", + ".pickleball/runs//", + ".pickleball/agent-log", + ".pickleball/inbox//", + ".pickleball/investigations//", + ".pickleball/presence/.json", + ".pickleball/posts/.json", + ".pickleball/history.log", +) +BOARD_VERBS = ( + "presence --touch", + "presence --list", + "presence --sweep", + "post --write", + "post --list", + "post --renew", + "post --sweep", + "history --append", + "history --tail", + "gc-runs", +) + def is_included(path: Path) -> bool: if not path.is_file(): @@ -177,6 +202,21 @@ def render() -> str: lines.append("- _No matching files found._") lines.append("") + lines.extend(( + "## .pickleball owners", + "", + "Jar cache is regenerable under `v//`, `open/`, and `current.json`.", + "Project history stays at the `.pickleball` root and survives export-guidance.", + "Legacy `.pickleball/workbench/` is only the fallback when `current.json` is missing or not usable.", + "", + )) + lines.extend(f"- `{path}`" for path in BOARD_PATHS) + lines.append("") + lines.append("Launcher verbs, same exec.args style as inbox, note, and short-log:") + lines.append("") + lines.extend(f"- `{verb}`" for verb in BOARD_VERBS) + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/scripts/verify_agent_contract.py b/scripts/verify_agent_contract.py index ffb232f4a..6c0aaef4b 100644 --- a/scripts/verify_agent_contract.py +++ b/scripts/verify_agent_contract.py @@ -498,6 +498,28 @@ def validate_dependency_owned_guidance(errors: list[str]) -> None: errors.append( "AGENT-GUIDE live isolation loop must use isolate then execute-step." ) + for required in ( + "presence --touch", + "post --write", + "history --append", + "gc-runs", + ".pickleball/investigations/", + ".pickleball/presence/", + ".pickleball/posts/", + ".pickleball/history.log", + "does not mean an old run", + "Agents are not daemons", + "one driver", + ): + if required not in text: + errors.append( + f"Dependency-owned consumer guide must state the board contract ({required}): " + "docs/consumer-agent-guide.md" + ) + if "new handoffs go under `.pickleball/v/" in text or "versioned under v//investigations when current.json is complete" in text: + errors.append( + "docs/consumer-agent-guide.md must not send new investigations under v//." + ) if maintainer < text.find("## When the core Pickleball repository is also present"): errors.append( "@agent-pointer-eval guidance must stay maintainer-only at the bottom of " diff --git a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java index 90c45d378..d0a015664 100644 --- a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java +++ b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.control.protocol.PickleballLocalLayout; +import tools.dscode.control.protocol.WindowDriver; import tools.dscode.testengine.PKB_props; import tools.dscode.testengine.PickleballRunner; @@ -23,30 +24,42 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; /** * Shared bulletin board for consumer agents, with private data per run. * - *

The short log and inbox live under the consumer {@code .pickleball} directory. - * A private {@code .pickleball/runs/} directory is only for an agent run - * or a Workbench run. A normal test does not create that folder and does not - * force a browser profile. Reports, diagnostic packs, scratch, per-worker - * browser profiles, and a headless Workbench session file for a private run - * live under that directory. Appending the short log does not take a file lock. - * A dead agent must not block the others. Lines older than three days are - * dropped on the next append.

+ *

Project history stays at the {@code .pickleball} root: the short log, + * inbox, runs, investigations, presence, posts, and {@code history.log}. + * The jar cache under {@code v//} is separate. Appending the short + * log does not take a file lock. A dead agent must not block the others. + * Lines older than three days are dropped on the next append. A dropped run + * whose directory is still there keeps one index stub. Launcher commands are + * one-shot. They are not daemons, and they do not delete a run, a profile, + * or this log on finish.

*/ public final class AgentCoordination { public static final String LOG_FILE = "agent-log"; public static final String INBOX_DIRECTORY = "inbox"; public static final String RUNS_DIRECTORY = "runs"; + public static final String PRESENCE_DIRECTORY = "presence"; + public static final String POSTS_DIRECTORY = "posts"; + public static final String HISTORY_FILE = "history.log"; public static final String ANY_AGENT = "any"; + public static final String ALL_AGENTS = "all"; public static final String RECORD_FILE = "record.json"; + public static final String RUN_PROFILE_FILE = "pkb_run_profile"; public static final Duration RETENTION = Duration.ofDays(3); + public static final Duration PRESENCE_STALE = Duration.ofMinutes(15); + public static final Duration POST_TTL = Duration.ofHours(24); + public static final Duration RUN_PAYLOAD_STALE = Duration.ofHours(24); + public static final long HISTORY_MAX_BYTES = 10L * 1024L * 1024L; public static final int PURPOSE_LIMIT = 240; private static final ObjectMapper JSON = new ObjectMapper() @@ -442,7 +455,7 @@ record == null ? "-" : oneField(record.toString()) )) { channel.write(ByteBuffer.wrap(bytes)); } - pruneIfStale(file, clock); + pruneIfStale(project, file, clock); } public static List readLog(Path project) throws IOException { @@ -485,6 +498,9 @@ public static Path writeInbox(Path project, String toAgent, String fromAgent, St if (text.contains("\n") || text.contains("\r")) { throw new IllegalArgumentException("An inbox note is one line."); } + if (ANY_AGENT.equals(target)) { + return writePost(project, from, ALL_AGENTS, null, text, null, Instant.now()).file(); + } Path dir = inboxDirectory(project, target); String name = Instant.now().toEpochMilli() + "-" + UUID.randomUUID() + ".json"; Path file = dir.resolve(name); @@ -506,6 +522,280 @@ public static List takeInbox(Path project, String agentId) { return List.copyOf(readInbox(project, agentId, true)); } + public record Presence(String agentId, String lastSeen, List runIds, Path file) { + } + + public record BoardPost( + String id, + String from, + String to, + String runId, + String text, + String createdAt, + String updatedAt, + String expiresAt, + List ackedBy, + Path file + ) { + } + + public record RunSweep(int profilesRemoved, int payloadsRemoved) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record PresenceFile(String agentId, String lastSeen, List runIds) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record PostFile( + String id, + String from, + String to, + String runId, + String text, + String createdAt, + String updatedAt, + String expiresAt, + List ackedBy + ) { + } + + public static Presence touchPresence(Path project, String agentId, String runId, Instant when) { + String agent = requireSafeId(agentId, "agent id"); + if (ANY_AGENT.equals(agent) || ALL_AGENTS.equals(agent)) { + throw new IllegalArgumentException("presence is for one agent, not '" + agent + "'."); + } + Instant clock = when == null ? Instant.now() : when; + Path file = presenceFile(project, agent); + List runs = new ArrayList<>(); + if (Files.isRegularFile(file)) { + try { + PresenceFile existing = JSON.readValue(file.toFile(), PresenceFile.class); + if (existing.runIds() != null) { + for (String id : existing.runIds()) { + if (id != null && !id.isBlank() && !runs.contains(id)) runs.add(id); + } + } + } catch (IOException ignored) { + // A broken row is replaced by this touch. + } + } + if (runId != null && !runId.isBlank()) { + String safeRun = requireSafeId(runId, "run id"); + if (!runs.contains(safeRun)) runs.add(safeRun); + } + PresenceFile body = new PresenceFile(agent, clock.toString(), List.copyOf(runs)); + try { + writeJson(file, body); + } catch (IOException failure) { + throw new IllegalStateException("Could not write presence for " + agent + ": " + failure.getMessage(), failure); + } + return new Presence(agent, body.lastSeen(), body.runIds(), file); + } + + public static List listPresence(Path project) { + Path dir = presenceDirectory(project); + if (!Files.isDirectory(dir)) return List.of(); + List rows = new ArrayList<>(); + for (Path file : noteFiles(dir)) { + try { + PresenceFile body = JSON.readValue(file.toFile(), PresenceFile.class); + String agent = body.agentId() == null || body.agentId().isBlank() + ? file.getFileName().toString().replaceFirst("\\.json$", "") + : body.agentId(); + List runs = body.runIds() == null ? List.of() : List.copyOf(body.runIds()); + rows.add(new Presence(agent, body.lastSeen() == null ? "" : body.lastSeen(), runs, file)); + } catch (IOException ignored) { + // An unreadable row is not a heartbeat. + } + } + rows.sort(Comparator.comparing(Presence::agentId)); + return List.copyOf(rows); + } + + /** Deletes expired presence rows only. Posts, runs, profiles, and history.log stay. */ + public static int sweepPresence(Path project, Instant when) { + Instant clock = when == null ? Instant.now() : when; + int removed = 0; + for (Presence row : listPresence(project)) { + if (!presenceExpired(row.lastSeen(), clock)) continue; + try { + if (row.file() != null && Files.deleteIfExists(row.file())) removed++; + } catch (IOException ignored) { + // Another sweeper already removed the row. + } + } + Path dir = presenceDirectory(project); + if (!Files.isDirectory(dir)) return removed; + for (Path file : noteFiles(dir)) { + if (readablePresence(file)) continue; + try { + if (Files.deleteIfExists(file)) removed++; + } catch (IOException ignored) { + // Idempotent: a second delete of the same file is fine. + } + } + return removed; + } + + public static BoardPost writePost( + Path project, + String fromAgent, + String toAgent, + String runId, + String message, + Duration ttl, + Instant when + ) { + String from = fromAgent == null || fromAgent.isBlank() ? newAgentId() : requireSafeId(fromAgent, "agent id"); + String to = normalizePostTarget(toAgent); + String text = onePostLine(message); + Instant clock = when == null ? Instant.now() : when; + Duration life = ttl == null ? POST_TTL : ttl; + if (life.isZero() || life.isNegative()) { + throw new IllegalArgumentException("A post ttl must be positive. The default is 24 hours."); + } + String id = clock.toEpochMilli() + "-" + UUID.randomUUID().toString().substring(0, 8); + String run = runId == null || runId.isBlank() ? null : requireSafeId(runId, "run id"); + String created = clock.toString(); + String expires = clock.plus(life).toString(); + PostFile body = new PostFile(id, from, to, run, text, created, created, expires, List.of()); + Path file = postsDirectory(project).resolve(id + ".json"); + try { + writeJson(file, body); + } catch (IOException failure) { + throw new IllegalStateException("Could not write post: " + failure.getMessage(), failure); + } + return toBoardPost(body, file); + } + + public static List listPosts(Path project, Instant when) { + Instant clock = when == null ? Instant.now() : when; + List posts = new ArrayList<>(); + for (Path file : noteFiles(postsDirectory(project))) { + BoardPost post = readPost(file); + if (post == null || postExpired(post.expiresAt(), clock)) continue; + posts.add(post); + } + posts.sort(Comparator.comparing(BoardPost::updatedAt).thenComparing(BoardPost::id)); + return List.copyOf(posts); + } + + public static BoardPost renewPost(Path project, String id, Duration ttl, Instant when) { + String safe = requireSafeId(stripJsonSuffix(id), "post id"); + Path file = postsDirectory(project).resolve(safe + ".json"); + BoardPost existing = readPost(file); + if (existing == null) { + throw new IllegalArgumentException("No post " + safe + " at " + file); + } + Instant clock = when == null ? Instant.now() : when; + Duration life = ttl == null ? POST_TTL : ttl; + if (life.isZero() || life.isNegative()) { + throw new IllegalArgumentException("A post ttl must be positive. The default is 24 hours."); + } + PostFile body = new PostFile( + existing.id(), + existing.from(), + existing.to(), + existing.runId(), + existing.text(), + existing.createdAt(), + clock.toString(), + clock.plus(life).toString(), + existing.ackedBy() + ); + try { + writeJson(file, body); + } catch (IOException failure) { + throw new IllegalStateException("Could not renew post " + safe + ": " + failure.getMessage(), failure); + } + return toBoardPost(body, file); + } + + /** Deletes expired post files only. Runs, profiles, presence, and history.log stay. */ + public static int sweepPosts(Path project, Instant when) { + Instant clock = when == null ? Instant.now() : when; + int removed = 0; + for (Path file : noteFiles(postsDirectory(project))) { + BoardPost post = readPost(file); + boolean expired = post == null || postExpired(post.expiresAt(), clock); + if (!expired) continue; + try { + if (Files.deleteIfExists(file)) removed++; + } catch (IOException ignored) { + // Two deletes of the same file are fine. + } + } + return removed; + } + + public static Path historyFile(Path project) { + return PickleballLocalLayout.root(project).resolve(HISTORY_FILE); + } + + public static void appendHistory(Path project, String text) { + String line = onePostLine(text); + Path file = historyFile(project); + try { + Files.createDirectories(file.getParent()); + byte[] bytes = (line + "\n").getBytes(StandardCharsets.UTF_8); + try (FileChannel channel = FileChannel.open( + file, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND + )) { + channel.write(ByteBuffer.wrap(bytes)); + } + trimHistory(file); + } catch (IOException failure) { + throw new IllegalStateException("Could not append history.log: " + failure.getMessage(), failure); + } + } + + public static List tailHistory(Path project, int limit) throws IOException { + Path file = historyFile(project); + if (!Files.isRegularFile(file)) return List.of(); + List lines = new ArrayList<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + if (!line.isBlank()) lines.add(line); + } + if (limit <= 0 || lines.size() <= limit) return List.copyOf(lines); + return List.copyOf(lines.subList(lines.size() - limit, lines.size())); + } + + /** + * Explicit payload sweep. Not called from finish. Deletes a browser profile + * only when that run has a stop line, nothing has the profile open, and no + * agent holds the run. Other dense payload waits 24 hours after stoppedAt, + * and never removes the run open in Workbench or the last failed run. + * record.json, sparse indexes, and pkb_run_profile stay. The run directory + * stays. Two sweepers deleting the same file are safe. + */ + public static RunSweep sweepRuns(Path project, Instant when) { + Instant clock = when == null ? Instant.now() : when; + Path runs = PickleballLocalLayout.root(project).resolve(RUNS_DIRECTORY); + if (!Files.isDirectory(runs)) return new RunSweep(0, 0); + List directories; + try (var children = Files.list(runs)) { + directories = children.filter(Files::isDirectory).toList(); + } catch (IOException ignored) { + return new RunSweep(0, 0); + } + String lastFailed = lastFailedRun(directories); + int profiles = 0; + int payloads = 0; + for (Path directory : directories) { + RunRecord record = readRecordQuiet(directory.resolve(RECORD_FILE)); + if (record == null || held(project, directory, record, clock)) continue; + if (deleteTreeContents(directory.resolve("browser-profile"))) profiles++; + if (record.runId() != null && record.runId().equals(lastFailed)) continue; + if (!payloadExpired(record, clock)) continue; + if (deleteDensePayload(directory)) payloads++; + } + return new RunSweep(profiles, payloads); + } + public static List mavenProperties(Run run) { List properties = new ArrayList<>(); properties.add("-D" + PKB_props.PKB_RUN_ID + "=" + run.runId()); @@ -540,10 +830,10 @@ public static String oneLine(String value) { private static List readInbox(Path project, String agentId, boolean take) { List files = new ArrayList<>(); - if (agentId != null && !agentId.isBlank() && !ANY_AGENT.equalsIgnoreCase(agentId.trim())) { + if (agentId != null && !agentId.isBlank() && !ANY_AGENT.equalsIgnoreCase(agentId.trim()) + && !ALL_AGENTS.equalsIgnoreCase(agentId.trim())) { files.addAll(noteFiles(inboxDirectory(project, requireSafeId(agentId, "agent id")))); } - files.addAll(noteFiles(inboxDirectory(project, ANY_AGENT))); files.sort(Comparator.comparing(path -> path.getFileName().toString())); List notes = new ArrayList<>(); for (Path file : files) { @@ -611,16 +901,18 @@ private static Run toRun(Path project, Path data, Path recordFile, RunRecord rec ); } - private static void pruneIfStale(Path file, Instant now) throws IOException { + private static void pruneIfStale(Path project, Path file, Instant now) throws IOException { if (!Files.isRegularFile(file)) return; byte[] snapshot = Files.readAllBytes(file); if (!containsStale(snapshot, now)) return; byte[] latest = Files.readAllBytes(file); byte[] source = latest.length >= snapshot.length ? latest : snapshot; - String filtered = filterLines(source, now); + LinkedHashMap dropped = new LinkedHashMap<>(); + String filtered = filterLines(source, now, dropped); if (latest.length > source.length) { - filtered = filtered + filterLines(slice(latest, source.length), now); + filtered = filtered + filterLines(slice(latest, source.length), now, dropped); } + filtered = filtered + indexStubs(filtered, dropped, project, now); Path temp = file.resolveSibling(file.getFileName() + ".prune-" + UUID.randomUUID()); Files.writeString(temp, filtered, StandardCharsets.UTF_8); try { @@ -639,18 +931,65 @@ private static boolean containsStale(byte[] bytes, Instant now) { return false; } - private static String filterLines(byte[] bytes, Instant now) { + private static String filterLines(byte[] bytes, Instant now, LinkedHashMap dropped) { Instant cutoff = now.minus(RETENTION); StringBuilder kept = new StringBuilder(); for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n", -1)) { if (line.isBlank()) continue; Instant stamp = timestampOf(line); - if (stamp != null && stamp.isBefore(cutoff)) continue; + if (stamp != null && stamp.isBefore(cutoff)) { + rememberDropped(dropped, line); + continue; + } kept.append(line).append('\n'); } return kept.toString(); } + private static void rememberDropped(LinkedHashMap dropped, String line) { + String[] fields = line.split("\t", -1); + if (fields.length < 3) return; + String runId = fields[2].trim(); + if (runId.isBlank() || "-".equals(runId) || runId.contains("..") || runId.contains("/") || runId.contains("\\")) { + return; + } + dropped.put(runId, fields); + } + + private static String indexStubs( + String kept, + LinkedHashMap dropped, + Path project, + Instant now + ) { + if (dropped.isEmpty() || project == null) return ""; + Set present = new LinkedHashSet<>(); + for (String line : kept.split("\n", -1)) { + if (line.isBlank()) continue; + String[] fields = line.split("\t", -1); + if (fields.length > 2) present.add(fields[2].trim()); + } + StringBuilder stubs = new StringBuilder(); + Path runs = PickleballLocalLayout.root(project).resolve(RUNS_DIRECTORY); + for (var entry : dropped.entrySet()) { + String runId = entry.getKey(); + if (present.contains(runId)) continue; + if (!Files.isDirectory(runs.resolve(runId))) continue; + String[] fields = entry.getValue(); + String agent = fields.length > 1 && !fields[1].isBlank() ? fields[1] : "-"; + String record = fields.length > 5 && !fields[5].isBlank() ? fields[5] : "-"; + stubs.append(String.join("\t", + now.toString(), + oneField(agent), + oneField(runId), + "index", + "pruned", + oneField(record) + )).append('\n'); + } + return stubs.toString(); + } + private static byte[] slice(byte[] bytes, int from) { byte[] extra = new byte[bytes.length - from]; System.arraycopy(bytes, from, extra, 0, extra.length); @@ -756,6 +1095,281 @@ private static void print(Appendable out, String line) { } } + private static final Set SPARSE_NAMES = Set.of( + "record.json", + "run-id.txt", + "run-index.json", + "summary.json", + "run-catalog.json", + RUN_PROFILE_FILE, + RUN_PROFILE_FILE + ".json", + RUN_PROFILE_FILE + ".txt" + ); + + private static Path presenceDirectory(Path project) { + return PickleballLocalLayout.root(project).resolve(PRESENCE_DIRECTORY); + } + + private static Path presenceFile(Path project, String agentId) { + return presenceDirectory(project).resolve(agentId + ".json"); + } + + private static Path postsDirectory(Path project) { + return PickleballLocalLayout.root(project).resolve(POSTS_DIRECTORY); + } + + private static boolean presenceExpired(String lastSeen, Instant now) { + if (lastSeen == null || lastSeen.isBlank()) return true; + try { + return lastSeenInstant(lastSeen).isBefore(now.minus(PRESENCE_STALE)); + } catch (RuntimeException ignored) { + return true; + } + } + + private static Instant lastSeenInstant(String lastSeen) { + return Instant.parse(lastSeen.trim()); + } + + private static boolean readablePresence(Path file) { + try { + PresenceFile body = JSON.readValue(file.toFile(), PresenceFile.class); + return body.lastSeen() != null && !body.lastSeen().isBlank() && lastSeenInstant(body.lastSeen()) != null; + } catch (Exception ignored) { + return false; + } + } + + private static String normalizePostTarget(String toAgent) { + if (toAgent == null || toAgent.isBlank() + || ANY_AGENT.equalsIgnoreCase(toAgent.trim()) + || ALL_AGENTS.equalsIgnoreCase(toAgent.trim())) { + return ALL_AGENTS; + } + return requireSafeId(toAgent, "agent id"); + } + + private static String onePostLine(String message) { + String text = message == null ? "" : message.strip(); + if (text.isEmpty()) throw new IllegalArgumentException("The line must not be empty."); + if (text.contains("\n") || text.contains("\r")) { + throw new IllegalArgumentException("The line must be one line."); + } + return oneLine(text); + } + + private static BoardPost readPost(Path file) { + if (file == null || !Files.isRegularFile(file)) return null; + try { + return toBoardPost(JSON.readValue(file.toFile(), PostFile.class), file); + } catch (IOException ignored) { + return null; + } + } + + private static BoardPost toBoardPost(PostFile body, Path file) { + String id = body.id() == null || body.id().isBlank() + ? stripJsonSuffix(file.getFileName().toString()) + : body.id(); + List acked = body.ackedBy() == null ? List.of() : List.copyOf(body.ackedBy()); + return new BoardPost( + id, + body.from() == null ? "" : body.from(), + body.to() == null ? ALL_AGENTS : body.to(), + blankToNull(body.runId()), + body.text() == null ? "" : body.text(), + body.createdAt() == null ? "" : body.createdAt(), + body.updatedAt() == null ? "" : body.updatedAt(), + body.expiresAt() == null ? "" : body.expiresAt(), + acked, + file + ); + } + + private static boolean postExpired(String expiresAt, Instant now) { + if (expiresAt == null || expiresAt.isBlank()) return true; + try { + return !Instant.parse(expiresAt.trim()).isAfter(now); + } catch (RuntimeException ignored) { + return true; + } + } + + private static String stripJsonSuffix(String name) { + if (name == null) return ""; + return name.endsWith(".json") ? name.substring(0, name.length() - 5) : name; + } + + private static void trimHistory(Path file) throws IOException { + if (!Files.isRegularFile(file) || Files.size(file) <= HISTORY_MAX_BYTES) return; + byte[] bytes = Files.readAllBytes(file); + if (bytes.length <= HISTORY_MAX_BYTES) return; + int cut = bytes.length - (int) HISTORY_MAX_BYTES; + while (cut < bytes.length && bytes[cut] != '\n') cut++; + if (cut < bytes.length) cut++; + if (cut >= bytes.length) cut = bytes.length - (int) HISTORY_MAX_BYTES; + byte[] tail = slice(bytes, cut); + Path temp = file.resolveSibling(file.getFileName() + ".trim-" + UUID.randomUUID()); + Files.write(temp, tail); + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static boolean held(Path project, Path directory, RunRecord record, Instant now) { + if (record.stoppedAt() == null || record.stoppedAt().isBlank()) return true; + if (record.status() != null && "RUNNING".equalsIgnoreCase(record.status().trim())) return true; + if (Files.isRegularFile(directory.resolve("session").resolve("cli-session.json"))) return true; + if (liveWorker(directory)) return true; + if (profileOpen(directory)) return true; + if (windowHolds(project, record.runId())) return true; + return agentHolds(project, record.runId(), now); + } + + private static boolean liveWorker(Path directory) { + Path session = directory.resolve("session"); + if (Files.isRegularFile(session.resolve("worker.json"))) return true; + Path pidFile = session.resolve("worker.pid"); + if (!Files.isRegularFile(pidFile)) return false; + try { + long pid = Long.parseLong(Files.readString(pidFile, StandardCharsets.UTF_8).trim()); + return ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + } catch (Exception ignored) { + return true; + } + } + + private static boolean profileOpen(Path directory) { + Path profile = directory.resolve("browser-profile"); + if (!Files.isDirectory(profile)) return false; + try (var paths = Files.walk(profile)) { + return paths.anyMatch(path -> { + Path name = path.getFileName(); + return name != null && "SingletonLock".equals(name.toString()); + }); + } catch (IOException ignored) { + return true; + } + } + + private static boolean windowHolds(Path project, String runId) { + if (runId == null || runId.isBlank()) return false; + WindowDriver.State state = WindowDriver.read(project); + if (!state.open()) return false; + return runId.equals(state.liveRunId()) || runId.equals(state.viewedRunId()); + } + + private static boolean agentHolds(Path project, String runId, Instant now) { + if (runId == null || runId.isBlank()) return false; + for (Presence row : listPresence(project)) { + if (presenceExpired(row.lastSeen(), now)) continue; + if (row.runIds() != null && row.runIds().contains(runId)) return true; + } + return false; + } + + private static String lastFailedRun(List directories) { + String winner = null; + Instant latest = null; + for (Path directory : directories) { + RunRecord record = readRecordQuiet(directory.resolve(RECORD_FILE)); + if (record == null || record.status() == null || !"FAILED".equalsIgnoreCase(record.status().trim())) { + continue; + } + if (record.stoppedAt() == null || record.stoppedAt().isBlank()) continue; + Instant stopped; + try { + stopped = Instant.parse(record.stoppedAt().trim()); + } catch (RuntimeException ignored) { + continue; + } + if (latest == null || stopped.isAfter(latest)) { + latest = stopped; + winner = record.runId(); + } + } + return winner; + } + + private static boolean payloadExpired(RunRecord record, Instant now) { + if (record.stoppedAt() == null || record.stoppedAt().isBlank()) return false; + try { + return !Instant.parse(record.stoppedAt().trim()).isAfter(now.minus(RUN_PAYLOAD_STALE)); + } catch (RuntimeException ignored) { + return false; + } + } + + private static RunRecord readRecordQuiet(Path file) { + if (!Files.isRegularFile(file)) return null; + try { + return readRecord(file); + } catch (IOException ignored) { + return null; + } + } + + private static boolean deleteTreeContents(Path root) { + if (!Files.exists(root)) return false; + boolean removed = false; + List paths; + try (var walk = Files.walk(root)) { + paths = walk.sorted(Comparator.reverseOrder()).toList(); + } catch (IOException ignored) { + return false; + } + for (Path path : paths) { + if (path.equals(root)) continue; + try { + if (Files.deleteIfExists(path)) removed = true; + } catch (IOException ignored) { + // Another sweeper removed it, or it is still open. + } + } + try { + Files.deleteIfExists(root); + } catch (IOException ignored) { + // The parent run directory is not this path. + } + return removed; + } + + private static boolean deleteDensePayload(Path runDirectory) { + boolean removed = false; + List paths; + try (var walk = Files.walk(runDirectory)) { + paths = walk.sorted(Comparator.reverseOrder()).toList(); + } catch (IOException ignored) { + return false; + } + for (Path path : paths) { + if (path.equals(runDirectory)) continue; + if (keepPayload(runDirectory, path)) continue; + try { + if (Files.deleteIfExists(path)) removed = true; + } catch (IOException ignored) { + // Two sweepers removing the same file are fine. + } + } + return removed; + } + + private static boolean keepPayload(Path runDirectory, Path path) { + Path name = path.getFileName(); + if (name != null && SPARSE_NAMES.contains(name.toString())) return true; + Path config = runDirectory.resolve("config"); + Path relative; + try { + relative = runDirectory.relativize(path); + } catch (IllegalArgumentException ignored) { + return true; + } + return relative.getNameCount() > 0 && "config".equals(relative.getName(0).toString()) + || path.startsWith(config); + } + static String normalizeEvent(String event) { if (event == null || event.isBlank()) return "note"; return event.trim().toLowerCase(Locale.ROOT); diff --git a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java index 4ad0e1090..e1f8814d4 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java @@ -48,6 +48,10 @@ static int run( case "note" -> note(parsed, out, err); case "inbox" -> inbox(parsed, out, err); case "finish" -> finish(parsed, out, err); + case "presence" -> presence(parsed, out, err); + case "post" -> post(parsed, out, err); + case "history" -> history(parsed, out, err); + case "gc-runs" -> gcRuns(parsed, out, err); default -> { err.println("Unknown Workbench agent command: " + parsed.command()); yield 2; @@ -278,6 +282,155 @@ private static int inbox(WorkbenchCommandLine.Parsed parsed, PrintStream out, Pr return list || take ? 0 : 2; } + private static int presence(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + WorkbenchCommandLine.Coordination flags = parsed.coordination(); + if (flags.sweep()) { + int removed = AgentCoordination.sweepPresence(parsed.project(), java.time.Instant.now()); + out.println("presence-removed=" + removed); + return 0; + } + if (flags.inboxList() && !flags.touch()) { + java.util.List rows = AgentCoordination.listPresence(parsed.project()); + if (rows.isEmpty()) { + out.println("(no presence)"); + return 0; + } + for (AgentCoordination.Presence row : rows) { + out.println(row.agentId() + "\t" + row.lastSeen() + "\t" + String.join(",", row.runIds())); + } + return 0; + } + String agent = flags.agentId(); + if (agent == null || agent.isBlank()) { + err.println("Usage: presence --touch --agent= [--run-id=] | presence --list | presence --sweep"); + return 2; + } + AgentCoordination.Presence row = AgentCoordination.touchPresence( + parsed.project(), agent, flags.runId(), java.time.Instant.now() + ); + out.println("presence=" + row.file()); + return 0; + } + + private static int post(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + WorkbenchCommandLine.Coordination flags = parsed.coordination(); + if (flags.sweep()) { + int removed = AgentCoordination.sweepPosts(parsed.project(), java.time.Instant.now()); + out.println("posts-removed=" + removed); + return 0; + } + java.time.Duration ttl = null; + if (flags.ttl() != null) { + try { + ttl = parseTtl(flags.ttl()); + } catch (IllegalArgumentException failure) { + err.println(failure.getMessage()); + return 2; + } + } + if (flags.renew()) { + if (flags.id() == null) { + err.println("Usage: post --renew --id= [--ttl=1h]"); + return 2; + } + AgentCoordination.BoardPost renewed = AgentCoordination.renewPost( + parsed.project(), flags.id(), ttl, java.time.Instant.now() + ); + out.println("post=" + renewed.file()); + return 0; + } + boolean write = flags.inboxWrite() || (flags.text() != null && !flags.inboxList()); + if (write) { + String message = firstText(parsed); + if (message == null || flags.inboxTo() == null) { + err.println("Usage: post --write --to= --text= [--run-id=] [--ttl=1h]"); + return 2; + } + String from = flags.inboxFrom() != null ? flags.inboxFrom() : flags.agentId(); + AgentCoordination.BoardPost written = AgentCoordination.writePost( + parsed.project(), + from, + flags.inboxTo(), + flags.runId(), + message, + ttl, + java.time.Instant.now() + ); + out.println("post=" + written.file()); + return 0; + } + java.util.List posts = AgentCoordination.listPosts( + parsed.project(), java.time.Instant.now() + ); + if (posts.isEmpty()) { + out.println("(no posts)"); + return 0; + } + for (AgentCoordination.BoardPost note : posts) { + out.println(note.id() + "\t" + note.from() + "\t" + note.to() + "\t" + note.expiresAt() + "\t" + note.text()); + } + return 0; + } + + private static int history(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + WorkbenchCommandLine.Coordination flags = parsed.coordination(); + if (flags.append() || (flags.text() != null && !flags.inboxList())) { + String text = firstText(parsed); + if (text == null) { + err.println("Usage: history --append --text="); + return 2; + } + AgentCoordination.appendHistory(parsed.project(), text); + out.println("history=" + AgentCoordination.historyFile(parsed.project())); + return 0; + } + int limit = flags.limit() == null ? 40 : flags.limit(); + try { + java.util.List lines = AgentCoordination.tailHistory(parsed.project(), limit); + if (lines.isEmpty()) { + out.println("(no history)"); + return 0; + } + for (String line : lines) out.println(line); + return 0; + } catch (java.io.IOException failure) { + err.println("Could not read history.log: " + failure.getMessage()); + return 1; + } + } + + private static int gcRuns(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + AgentCoordination.RunSweep sweep = AgentCoordination.sweepRuns(parsed.project(), java.time.Instant.now()); + out.println("profiles-removed=" + sweep.profilesRemoved()); + out.println("payloads-removed=" + sweep.payloadsRemoved()); + return 0; + } + + private static java.time.Duration parseTtl(String raw) { + String value = raw.trim(); + if (value.startsWith("P") || value.startsWith("p")) { + try { + return java.time.Duration.parse(value.toUpperCase(java.util.Locale.ROOT)); + } catch (RuntimeException failure) { + throw new IllegalArgumentException("ttl must be a duration such as 1h or PT1H: " + raw); + } + } + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("^(\\d+)\\s*([smhd])$", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(value); + if (!matcher.matches()) { + throw new IllegalArgumentException("ttl must be a duration such as 1h, 30m, or PT1H: " + raw); + } + long amount = Long.parseLong(matcher.group(1)); + return switch (matcher.group(2).toLowerCase(java.util.Locale.ROOT)) { + case "s" -> java.time.Duration.ofSeconds(amount); + case "m" -> java.time.Duration.ofMinutes(amount); + case "h" -> java.time.Duration.ofHours(amount); + case "d" -> java.time.Duration.ofDays(amount); + default -> throw new IllegalArgumentException("ttl must be a duration such as 1h or PT1H: " + raw); + }; + } + private static int finish(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { String runId = parsed.coordination().runId(); if (runId == null || runId.isBlank()) { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java index 07ed34955..8e83f52c5 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java @@ -16,7 +16,8 @@ final class WorkbenchCommandLine { ); static final Set AGENT_CORE_COMMANDS = Set.of( "export-guidance", "hint", "discover-hint", "discover", "confirm", "resolve-runvars", - "short-log", "note", "inbox", "finish" + "short-log", "note", "inbox", "finish", + "presence", "post", "history", "gc-runs" ); static final Set SESSION_CLIENT_COMMANDS = Set.of( "isolate", "session-start", "execute-step", "status", "events", "stop", "kill", @@ -43,10 +44,19 @@ record Coordination( boolean inboxWrite, boolean inboxList, boolean inboxTake, - Integer limit + boolean touch, + boolean sweep, + boolean renew, + boolean append, + Integer limit, + String id, + String ttl ) { static Coordination empty() { - return new Coordination(null, null, null, null, null, null, null, null, null, null, false, false, false, null); + return new Coordination( + null, null, null, null, null, null, null, null, null, null, + false, false, false, false, false, false, false, null, null, null + ); } } @@ -105,7 +115,13 @@ static Parsed parse(String[] args) { boolean inboxWrite = false; boolean inboxList = false; boolean inboxTake = false; + boolean touch = false; + boolean sweep = false; + boolean renew = false; + boolean append = false; Integer limit = null; + String id = null; + String ttl = null; String absorb = null; Path project = null; List rest = new ArrayList<>(); @@ -274,6 +290,46 @@ static Parsed parse(String[] args) { absorb = null; continue; } + if ("--touch".equals(token)) { + touch = true; + absorb = null; + continue; + } + if ("--sweep".equals(token)) { + sweep = true; + absorb = null; + continue; + } + if ("--renew".equals(token)) { + renew = true; + absorb = null; + continue; + } + if ("--append".equals(token)) { + append = true; + absorb = null; + continue; + } + if (token.startsWith("--id=")) { + id = blankToNull(token.substring("--id=".length())); + absorb = null; + continue; + } + if ("--id".equals(token) && index + 1 < args.length) { + id = blankToNull(args[++index]); + absorb = null; + continue; + } + if (token.startsWith("--ttl=")) { + ttl = blankToNull(token.substring("--ttl=".length())); + absorb = null; + continue; + } + if ("--ttl".equals(token) && index + 1 < args.length) { + ttl = blankToNull(args[++index]); + absorb = null; + continue; + } if (isFlag(token)) { rest.add(token); continue; @@ -301,7 +357,8 @@ static Parsed parse(String[] args) { if (project == null) project = cwd; Coordination coordination = new Coordination( runId, agentId, group, sequence, who, why, learned, text, - inboxTo, inboxFrom, inboxWrite, inboxList, inboxTake, limit + inboxTo, inboxFrom, inboxWrite, inboxList, inboxTake, + touch, sweep, renew, append, limit, id, ttl ); if (isForwardedCommand(command)) { diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index 5bc668513..a9e59ff37 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -20,7 +20,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. @@ -37,9 +37,46 @@ Do not copy consumer features into `.pickleball` as a sandbox. ### Run coordination -Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder, a live session, or a config copy. Each private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. + +One window has one driver. Each agent uses its own run id. Launcher commands are one-shot and exit. A 10-minute presence sweep is optional and cannot be enforced. + +### Board + +Three files. Do not merge them into the inbox. + +1. Presence. `.pickleball/presence/.json` has the agent id, `lastSeen`, and current run ids. Update it when you want: `presence --touch --agent= [--run-id=]`, `presence --list`, `presence --sweep`. An active agent may drop a row whose `lastSeen` is older than 15 minutes. That drop removes the row only. It does not delete posts, runs, profiles, or `history.log`. A missing heartbeat means not on the board, not clear their work. Two sweepers are idempotent. +2. Posts. `.pickleball/posts/.json` has id, from, to (an agent id or `all`), optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, and optional `ackedBy`. `post --write --to= --text= [--run-id] [--ttl=]`, `post --list`, `post --renew --id=`, `post --sweep`. Every agent may list them. Do not delete on read. Ack is optional and is not required to clear. Default expiry is 24 hours from the last update, not from the first write. The author may set a shorter expiry. One hour is appropriate for "I have the window." Short expiry is not the default. Renew by updating `updatedAt` and `expiresAt`. A missing or unreadable timestamp counts as expired. After expiry, any active agent may delete that post file. Two deletes of the same file are fine. Deleting a post does not delete the run, the profile, the presence row, or `history.log`. A post is a hint, not a lock. Readers treat it as possibly stale even if it is new. +3. Summation log. `.pickleball/history.log` is append-only. One short line: version, files, fix, branch, commit, merge, run id. `history --append --text=` and `history --tail`. A missing run directory is normal. Trim from the front when the file exceeds 10 MB. Not a mailbox. Do not put keep-alives here. + +The short log `.pickleball/agent-log` stays the 3-day coordination tape. Appending does not take a file lock. On prune, a dropped run whose directory remains keeps a one-line stub so the index does not vanish. Do not put fix history in the short log. Do not add a lock a crashed agent can hold. + +### .pickleball has two owners + +Do not collapse `.pickleball` under `v/`. + +Jar cache, regenerable from the resolved dependency: + +- `.pickleball/v//` guidance, docs, the maven-consumer-project snapshot, `workbench/controller/`, and `workbench/lib/` +- `.pickleball/open/` +- root aliases `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` +- `current.json` written last: `pickleballVersion`, `complete`, `updatedAt` + +Project history. It must survive `export-guidance` and a version bump. It stays at the `.pickleball` root: + +- `runs//` +- `agent-log` +- `inbox/` +- `investigations/` +- `presence/`, `posts/`, `history.log` + +`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. New session, attach, last-discover, logs, and `live/classes` stay under `v//workbench` when `current.json` is complete. Do not describe both layouts as current. + +Export cleanup is a manifest diff of generated files in that version folder. There is no general `.pickleball` garbage collector. Do not delete other `v//` trees until the new `current.json` is complete and a launch has succeeded. Keeping the previous version tree is enough. Never automatically delete `current.json`, `open/`, or the `v//` tree named by a complete pointer, or an in-progress export (`complete: false`). + +New investigation handoffs always go to `.pickleball/investigations//`. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read the legacy versioned path. `export-guidance` leaves both trees alone. ### Live isolation loop @@ -58,7 +95,7 @@ After Discover has found the failing scenario, use the session that is already o - `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. - `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. -- `.pickleball/investigations/` is unmanaged consumer-agent output. `export-guidance` leaves it alone. +- `.pickleball/investigations/` is project history, not generated guidance. New handoffs always go there. A legacy `v//investigations/` tree is still readable when the root path for that id is absent. `export-guidance` leaves both trees alone. - `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. ## First-read @@ -90,7 +127,7 @@ A successful `export-guidance .pickleball` run: Any Pickleball execution (`PickleballRunner`, `java -jar pickleball.jar`, Workbench launcher) lazily materializes the running jar's version folder if it is missing. It unpacks from the running jar; it never copies a sibling version folder. Direct `java -jar pickleball-X.jar` pins `current.json` to X. Open scripts set `PKB_OPEN_BOOTSTRAP=1` so Java hops to the pinned jar when `current.json` is complete. -The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat any existing `.pickleball` contents as potentially stale. `complete: false` or a missing `current.json` means do not trust the tree. The manifest records the last completed export; it is not a substitute for rerunning the exporter. +The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. The manifest records the last completed export; it is not a substitute for rerunning the exporter. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Version folders keep each export isolated. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. 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 a22181563..407ae37c7 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 @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `ConfigTabModel`; `LongWork`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `ConfigTabModelTest`; `LongWorkTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `.pickleball/investigations/`; `.pickleball/presence/`; `.pickleball/posts/`; `.pickleball/history.log`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | 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 29f585d5c..85ca72f28 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 @@ -818,3 +818,33 @@ This inventory helps coding agents discover relevant files. It does not replace - `scripts/refresh_agent_index.py` - `scripts/sync_consumer_guidance.py` - `scripts/verify_agent_contract.py` + +## .pickleball owners + +Jar cache is regenerable under `v//`, `open/`, and `current.json`. +Project history stays at the `.pickleball` root and survives export-guidance. +Legacy `.pickleball/workbench/` is only the fallback when `current.json` is missing or not usable. + +- `.pickleball/v//` +- `.pickleball/open/` +- `.pickleball/current.json` +- `.pickleball/runs//` +- `.pickleball/agent-log` +- `.pickleball/inbox//` +- `.pickleball/investigations//` +- `.pickleball/presence/.json` +- `.pickleball/posts/.json` +- `.pickleball/history.log` + +Launcher verbs, same exec.args style as inbox, note, and short-log: + +- `presence --touch` +- `presence --list` +- `presence --sweep` +- `post --write` +- `post --list` +- `post --renew` +- `post --sweep` +- `history --append` +- `history --tail` +- `gc-runs` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md index 407bd0f61..a417a03aa 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/ai-run-configuration.md @@ -432,7 +432,7 @@ When operating in a consumer project: The agent-facing entry is Pickleball Workbench. Run the consumer project's wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Pick the script from the OS. Do not require a machine-wide Maven or Gradle install. `hint`, `discover`, and `confirm` find failures. `pkb_tags`, `pkb_name`, and `pkb_example` narrow a run. When a Workbench window is already open, drive that session with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run` so the person sees the result. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When nobody is watching, `isolate` / `execute-step` stay the headless live-debug path. Set a **complete** Discover `pkb_runvars` rather than a partial overlay. Workbench `hint` prints the browser-ladder result and estimated integer parallel count. The browser ladder keeps a remote project `pkb_browser` (`SAUCE_*` / `GRID_*` / `REMOTE_*`); otherwise it prefers `CHROME_HEADLESS`. Unused Sauce/Grid yaml files are not auto-selected. A blocked local driver download does not fail the run: Selenium Manager is tried first unless `pkb_driver_download_native=false`, and the fallback is skipped only when `pkb_driver_download_proxy=false`. An explicit `driver.service.driverExecutable` skips both. The proxy value is redacted. Do not invent a `configs` proxy block and do not copy it onto `HTTPS_PROXY` or `SE_PROXY`. See [Execution Configuration](configuration.md). -Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. `pkb_run_id`, `pkb_agent_id`, `pkb_run_group`, `pkb_run_sequence`, `pkb_run_who`, and `pkb_run_why` are coordination metadata, not RunVars, so a confirm replay does not reuse another run's directory or browser profile. ```text pkb_browser= diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index 8807a1294..6b2151356 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -122,13 +122,17 @@ Reports, diagnostic packs, scratch, and a headless Workbench session file for th The run record is `.pickleball/runs//record.json`: run id, agent id, optional group, sequence, who, why, what was learned, start time, stop time, status, and the path to the run data. The short log only points at that record. -The short log is `.pickleball/agent-log`. Read it before a run, after a run, and during a long session, instead of the dense diagnostic log. Each line is a timestamp, agent id, run id, `start` or `stop` or `note`, a one-line purpose, and the path to the run record. Appending does not take a file lock. A dead agent must not block the others. On each write, lines older than 3 days are dropped. Newer lines stay. +The short log is `.pickleball/agent-log`. Read it before a run, after a run, and during a long session, instead of the dense diagnostic log. Each line is a timestamp, agent id, run id, `start` or `stop` or `note`, a one-line purpose, and the path to the run record. Appending does not take a file lock. A dead agent must not block the others. On each write, lines older than 3 days are dropped. Newer lines stay. A dropped run whose directory remains keeps a one-line stub so the index does not vanish. Do not put fix history in the short log. -`inbox//` is a note for that agent. `inbox/any/` is a note any active agent may take. A note is one short file: who wrote it, timestamp, one message, optional path to a run record. The reader deletes the note after taking it. The inbox is not a second log and not an assignment framework. +`inbox//` is directed mail for that agent. `take` deletes that note. `list` does not. `inbox/any` is not first-taker-wins. A note to every agent is a post, not a consumed inbox file. The inbox is not a second log and not an assignment framework. -When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, stop a headless run, or erase `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. +The board is three files, not more inbox folders. `.pickleball/presence/.json` is a heartbeat (`agent id`, `lastSeen`, current run ids). An active agent may drop a row older than 15 minutes. That drop removes the row only. `.pickleball/posts/.json` is a hint (`id`, `from`, `to` an agent id or `all`, optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, optional `ackedBy`). Default expiry is 24 hours from the last update. One hour is appropriate for "I have the window" and is not the default. List does not delete. Ack is optional. `.pickleball/history.log` is an append-only summation (version, files, fix, branch, commit, merge, run id), trimmed from the front above 10 MB. It is not a mailbox. Agents are not daemons. Launcher commands are one-shot: `presence --touch|--list|--sweep`, `post --write|--list|--renew|--sweep`, `history --append|--tail`, and `gc-runs`. -Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. +`browser-profile/` under a run is deleted only by `gc-runs`, and only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Remote drivers and a consumer-supplied `user-data-dir` are not this directory. Dense evidence stays for the run open in Workbench and for the last failed run. Other stopped payloads may go 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile`. Never delete a run with no stop line. `finish` and `stop` only write `stoppedAt` and a stop line. + +When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, the profile, the session file, or `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. + +Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, `presence`, `post`, `history`, `gc-runs`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. They do not delete the run. ## Named profiles diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index 5bc668513..a9e59ff37 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -20,7 +20,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. @@ -37,9 +37,46 @@ Do not copy consumer features into `.pickleball` as a sandbox. ### Run coordination -Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. +Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers stay untouched. Options that already contain `user-data-dir` are left alone. Two runs do not share an output folder, a live session, or a config copy. Each private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves a note; `inbox --list` or `inbox --take --agent=` lists or takes that agent's notes plus `inbox/any`. Taking a note deletes it. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. + +One window has one driver. Each agent uses its own run id. Launcher commands are one-shot and exit. A 10-minute presence sweep is optional and cannot be enforced. + +### Board + +Three files. Do not merge them into the inbox. + +1. Presence. `.pickleball/presence/.json` has the agent id, `lastSeen`, and current run ids. Update it when you want: `presence --touch --agent= [--run-id=]`, `presence --list`, `presence --sweep`. An active agent may drop a row whose `lastSeen` is older than 15 minutes. That drop removes the row only. It does not delete posts, runs, profiles, or `history.log`. A missing heartbeat means not on the board, not clear their work. Two sweepers are idempotent. +2. Posts. `.pickleball/posts/.json` has id, from, to (an agent id or `all`), optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, and optional `ackedBy`. `post --write --to= --text= [--run-id] [--ttl=]`, `post --list`, `post --renew --id=`, `post --sweep`. Every agent may list them. Do not delete on read. Ack is optional and is not required to clear. Default expiry is 24 hours from the last update, not from the first write. The author may set a shorter expiry. One hour is appropriate for "I have the window." Short expiry is not the default. Renew by updating `updatedAt` and `expiresAt`. A missing or unreadable timestamp counts as expired. After expiry, any active agent may delete that post file. Two deletes of the same file are fine. Deleting a post does not delete the run, the profile, the presence row, or `history.log`. A post is a hint, not a lock. Readers treat it as possibly stale even if it is new. +3. Summation log. `.pickleball/history.log` is append-only. One short line: version, files, fix, branch, commit, merge, run id. `history --append --text=` and `history --tail`. A missing run directory is normal. Trim from the front when the file exceeds 10 MB. Not a mailbox. Do not put keep-alives here. + +The short log `.pickleball/agent-log` stays the 3-day coordination tape. Appending does not take a file lock. On prune, a dropped run whose directory remains keeps a one-line stub so the index does not vanish. Do not put fix history in the short log. Do not add a lock a crashed agent can hold. + +### .pickleball has two owners + +Do not collapse `.pickleball` under `v/`. + +Jar cache, regenerable from the resolved dependency: + +- `.pickleball/v//` guidance, docs, the maven-consumer-project snapshot, `workbench/controller/`, and `workbench/lib/` +- `.pickleball/open/` +- root aliases `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` +- `current.json` written last: `pickleballVersion`, `complete`, `updatedAt` + +Project history. It must survive `export-guidance` and a version bump. It stays at the `.pickleball` root: + +- `runs//` +- `agent-log` +- `inbox/` +- `investigations/` +- `presence/`, `posts/`, `history.log` + +`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. New session, attach, last-discover, logs, and `live/classes` stay under `v//workbench` when `current.json` is complete. Do not describe both layouts as current. + +Export cleanup is a manifest diff of generated files in that version folder. There is no general `.pickleball` garbage collector. Do not delete other `v//` trees until the new `current.json` is complete and a launch has succeeded. Keeping the previous version tree is enough. Never automatically delete `current.json`, `open/`, or the `v//` tree named by a complete pointer, or an in-progress export (`complete: false`). + +New investigation handoffs always go to `.pickleball/investigations//`. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read the legacy versioned path. `export-guidance` leaves both trees alone. ### Live isolation loop @@ -58,7 +95,7 @@ After Discover has found the failing scenario, use the session that is already o - `.pickleball/maven-consumer-project/` is a version-matched **read-only** reference snapshot of Pickleball's own example consumer. Do not copy, edit, or execute it as the project under test. - `.pickleball/workbench/live/classes` is the compiled overlay for the worker classpath. Do not use it as an editor. -- `.pickleball/investigations/` is unmanaged consumer-agent output. `export-guidance` leaves it alone. +- `.pickleball/investigations/` is project history, not generated guidance. New handoffs always go there. A legacy `v//investigations/` tree is still readable when the root path for that id is absent. `export-guidance` leaves both trees alone. - `export-guidance` does **not** copy this consumer's own features into `.pickleball` for testing. It still materializes full `docs/` plus the example-consumer snapshot for on-demand/human use. ## First-read @@ -90,7 +127,7 @@ A successful `export-guidance .pickleball` run: Any Pickleball execution (`PickleballRunner`, `java -jar pickleball.jar`, Workbench launcher) lazily materializes the running jar's version folder if it is missing. It unpacks from the running jar; it never copies a sibling version folder. Direct `java -jar pickleball-X.jar` pins `current.json` to X. Open scripts set `PKB_OPEN_BOOTSTRAP=1` so Java hops to the pinned jar when `current.json` is complete. -The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat any existing `.pickleball` contents as potentially stale. `complete: false` or a missing `current.json` means do not trust the tree. The manifest records the last completed export; it is not a substitute for rerunning the exporter. +The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. The manifest records the last completed export; it is not a substitute for rerunning the exporter. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Version folders keep each export isolated. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index 65530dc4d..10ad41712 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -30,7 +30,7 @@ Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current ve Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. -AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. +AI agents should read `.pickleball/AGENT-GUIDE.md` first after a successful export. Workbench is the one front door: `discover` then `confirm` to find failures. When a Workbench window is already open, drive it with `open-scenario`, `example`, `play`, `execute-step`, `stop`, and `diagnostic-run`. When nobody is watching, use `isolate` / `execute-step`. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Run the project wrapper (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post, not an inbox file, and listing or taking does not delete it. Do not treat `.pickleball/maven-consumer-project/` as the project under test, and do not dump `docs/README.md` or the whole snapshot into first-read context. Human readers can start with `.pickleball/docs/README.md` or the versioned copy; links from those guides to `maven-consumer-project` resolve to the exported version-matched reference files. ## Version-matched reference snapshot diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md b/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md index c80d2eb5f..b0926184c 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md @@ -143,7 +143,7 @@ Prefer `DiagnosticCli` over custom Maven-classpath/JShell workflows for routine report.html # one-page local render ``` -When `.pickleball/current.json` is complete, new handoffs go under `.pickleball/v//investigations/` instead. Legacy `.pickleball/investigations/` remains when no current pointer exists. +New handoffs always go to `.pickleball/investigations//`, even when `.pickleball/current.json` is complete. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read that legacy versioned path. Input is investigation JSON from a file or stdin (`-`) plus the consumer project root. The command prints the project-relative `report.html` path. JSON is the source of truth. HTML renders that JSON plus at most two screenshots *linked* from the existing diagnostic pack; extra screenshot paths are ignored, and a missing image becomes a short note rather than a failed emit. The writer does not copy `reports/diagnostic-runs/` and does not change `pkb_diagnostic_output`. Do not embed PNG bytes in investigation JSON. Headless Workbench MCP exposes the same emit as `workbench_investigation_emit` and returns only that relative report path. After emit, chat prints the six-line bottom-line block from `docs/consumer-agent-guide.md`, then that path. Portable `report.html` is an indented list in Gherkin/business-first order. Do not use Mermaid. @@ -183,7 +183,7 @@ links[] # optional workbench_go / wb:// targets Do not parse Gherkin in the Workbench controller to invent `executionMap`. If the agent omitted it, derive it from `events.jsonl` at emit. v1 JSON still renders. Workbench Report is the same JSON. -`export-guidance` does not manage or delete `.pickleball/investigations/` or versioned `v//investigations/`. +`export-guidance` does not manage or delete `.pickleball/investigations/` or any legacy `v//investigations/` tree. ## Outcomes and completion 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 7771f140e..b15f9af06 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 @@ -75,7 +75,7 @@ Run the project's wrapper. Pick the script from the OS: `mvnw` or `mvnw.cmd`, `g "-Dexec.args=finish --run-id= --learned='the empty row fails'" ``` -Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, `isolate` does not start a second session. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to a named agent or to any, and delete an inbox note after reading it. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. +Same launcher; only change `-Dexec.args`. When nobody is watching, `isolate` starts a detached headless session and later execs are one-shot HTTP clients. `isolate` still forces `pkb_parallel=1`. When a window is already open, only one agent drives it and `isolate` does not start a second session. One window has one driver. Agents are not daemons, and each agent uses its own run id. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. Presence, posts, and `history.log` stay separate from the inbox. Two headless isolates do not share a session file; each file lives under `.pickleball/runs//session/`. `finish` and `stop` do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. New session, attach, last-discover, logs, and `live/classes` live under `v//workbench` when `current.json` is complete. `.pickleball/workbench/` is only the fallback when that pointer is missing or not usable. `hint`, `discover`, `confirm`, and `isolate` accept `--example` / `--example=` the same way they accept `--tags` and `--name`. The value becomes `pkb_example` inside `pkb_runvars` (or inside `pkb_overriderunvars` when the Discover snapshot is sealed). That is the headless way to keep specific Examples rows. A normal Scenario counts as row 1. `pkb_example` is not a tag. `--example='1 2 5 3.4 7-11'` keeps those rows, in source order, in each scenario that tags and name already selected. The Play row picker calls the same `example` filter. Play shows the first source-order match. Headless `pkb_example` still runs every match. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. diff --git a/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java index 7bb394dae..c094d9ef6 100644 --- a/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java +++ b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java @@ -178,24 +178,25 @@ void inboxIsAddressedAndTakingDeletesTheNote() throws Exception { List forA = AgentCoordination.listInbox(project, "agent-a"); List forB = AgentCoordination.listInbox(project, "agent-b"); - List forC = AgentCoordination.listInbox(project, "agent-c"); - assertTrue(forA.stream().anyMatch(note -> note.message().equals("for a only"))); - assertFalse(forA.stream().anyMatch(note -> note.message().equals("for b only"))); - assertTrue(forA.stream().anyMatch(note -> note.message().equals("for anyone"))); - assertTrue(forB.stream().anyMatch(note -> note.message().equals("for b only"))); - assertFalse(forB.stream().anyMatch(note -> note.message().equals("for a only"))); - assertEquals(1, forC.size()); - assertEquals("for anyone", forC.getFirst().message()); + assertEquals(1, forA.size()); + assertEquals("for a only", forA.getFirst().message()); + assertEquals(1, forB.size()); + assertEquals("for b only", forB.getFirst().message()); + assertTrue(AgentCoordination.listInbox(project, "agent-c").isEmpty()); + assertTrue(any.toString().contains("posts")); + assertTrue(Files.isRegularFile(any)); List taken = AgentCoordination.takeInbox(project, "agent-a"); - assertEquals(2, taken.size()); - assertFalse(Files.exists(any)); + assertEquals(1, taken.size()); + assertEquals("for a only", taken.getFirst().message()); + assertFalse(Files.exists(taken.getFirst().file())); + assertTrue(Files.isRegularFile(any)); assertTrue(AgentCoordination.listInbox(project, "agent-a").isEmpty()); assertEquals(1, AgentCoordination.listInbox(project, "agent-b").size()); - assertEquals("for b only", AgentCoordination.listInbox(project, "agent-b").getFirst().message()); AgentCoordination.takeInbox(project, "agent-b"); assertTrue(AgentCoordination.listInbox(project, "agent-b").isEmpty()); - assertTrue(AgentCoordination.listInbox(project, "agent-c").isEmpty()); + assertTrue(Files.isRegularFile(any)); + assertEquals(1, AgentCoordination.listPosts(project, Instant.now()).size()); } @Test @@ -322,6 +323,192 @@ void agentAndWorkbenchRunsSuppressHtmlUntilAnExplicitTrue() throws Exception { assertFalse(Files.exists(workbenchReport)); } + @Test + void prunedRunKeepsAnIndexStubWhileItsDirectoryRemains() throws Exception { + Path runDir = AgentCoordination.runDirectory(project, "old-kept"); + Files.createDirectories(runDir); + Instant now = Instant.parse("2026-10-04T12:00:00Z"); + AgentCoordination.appendLog( + project, + now.minus(Duration.ofDays(3)).minusMillis(1), + "agent-old", + "old-kept", + "note", + "aged-out", + runDir.resolve("record.json") + ); + AgentCoordination.appendLog(project, now, "agent-new", "fresh-run", "note", "stay", null); + String log = Files.readString(AgentCoordination.logFile(project), StandardCharsets.UTF_8); + assertTrue(log.contains("old-kept")); + assertTrue(log.contains("\tindex\t")); + assertFalse(log.contains("aged-out")); + assertTrue(log.contains("fresh-run")); + assertTrue(Files.isDirectory(runDir)); + } + + @Test + void presenceSweepDeletesOnlyAnExpiredRow() throws Exception { + AgentCoordination.touchPresence(project, "agent-fresh", "run-fresh", Instant.now()); + AgentCoordination.touchPresence(project, "agent-stale", "run-stale", Instant.now().minus(Duration.ofMinutes(16))); + AgentCoordination.BoardPost post = AgentCoordination.writePost( + project, "agent-fresh", "all", null, "still here", null, Instant.now() + ); + AgentCoordination.appendHistory(project, "2.1.14 files fix branch commit merge run-fresh"); + AgentCoordination.Run run = AgentCoordination.begin(project, request("run-stale", "agent-stale")); + Path cookies = run.browserProfileDirectory().resolve("worker").resolve("Cookies"); + Files.createDirectories(cookies.getParent()); + Files.writeString(cookies, "keep"); + + int removed = AgentCoordination.sweepPresence(project, Instant.now()); + assertEquals(1, removed); + assertFalse(Files.exists(project.resolve(".pickleball/presence/agent-stale.json"))); + assertTrue(Files.isRegularFile(project.resolve(".pickleball/presence/agent-fresh.json"))); + assertTrue(Files.isRegularFile(post.file())); + assertTrue(Files.isRegularFile(AgentCoordination.historyFile(project))); + assertTrue(Files.isRegularFile(run.browserProfileDirectory().resolve("worker").resolve("Cookies"))); + assertEquals(0, AgentCoordination.sweepPresence(project, Instant.now())); + } + + @Test + void unexpiredPostSurvivesListAndTakeAndExpiredDeleteIsIdempotent() throws Exception { + AgentCoordination.BoardPost live = AgentCoordination.writePost( + project, "agent-writer", "all", "run-post", "I have the window", Duration.ofHours(1), Instant.now() + ); + AgentCoordination.BoardPost expired = AgentCoordination.writePost( + project, "agent-writer", "agent-other", null, "old hint", Duration.ofHours(1), + Instant.parse("2020-01-01T00:00:00Z") + ); + assertEquals(1, AgentCoordination.listPosts(project, Instant.now()).size()); + assertEquals("I have the window", AgentCoordination.listPosts(project, Instant.now()).getFirst().text()); + assertTrue(Files.isRegularFile(live.file())); + AgentCoordination.takeInbox(project, "agent-other"); + AgentCoordination.listInbox(project, "agent-other"); + assertTrue(Files.isRegularFile(live.file())); + assertTrue(Files.isRegularFile(expired.file())); + + assertEquals(1, AgentCoordination.sweepPosts(project, Instant.now())); + assertFalse(Files.exists(expired.file())); + assertTrue(Files.isRegularFile(live.file())); + assertEquals(0, AgentCoordination.sweepPosts(project, Instant.now())); + assertEquals(0, AgentCoordination.sweepPosts(project, Instant.now())); + } + + @Test + void historyTrimAtTenMegabytesKeepsTheTail() throws Exception { + Path file = AgentCoordination.historyFile(project); + Files.createDirectories(file.getParent()); + StringBuilder body = new StringBuilder(); + body.append("HEAD-MARKER 0 ").append("x".repeat(80)).append('\n'); + String filler = "FILL " + "y".repeat(200) + "\n"; + while (body.length() <= AgentCoordination.HISTORY_MAX_BYTES + filler.length()) { + body.append(filler); + } + Files.writeString(file, body.toString()); + String tail = "TAIL-MARKER version files fix branch commit merge run-z"; + AgentCoordination.appendHistory(project, tail); + assertTrue(Files.size(file) <= AgentCoordination.HISTORY_MAX_BYTES); + String text = Files.readString(file); + assertTrue(text.contains("TAIL-MARKER")); + assertFalse(text.contains("HEAD-MARKER 0 ")); + assertTrue(text.stripTrailing().endsWith(tail)); + } + + @Test + void browserProfileStaysWhileRunningWhileSessionIsLiveOrWhileTheWindowHoldsTheRun() throws Exception { + AgentCoordination.Run running = AgentCoordination.begin(project, request("run-live", "agent-live")); + Files.createDirectories(running.browserProfileDirectory().resolve("worker")); + Files.writeString(running.browserProfileDirectory().resolve("worker").resolve("Cookies"), "live"); + AgentCoordination.sweepRuns(project, Instant.now()); + assertTrue(Files.isRegularFile(running.browserProfileDirectory().resolve("worker").resolve("Cookies"))); + + AgentCoordination.Run session = AgentCoordination.begin(project, request("run-session", "agent-session")); + Files.createDirectories(session.browserProfileDirectory().resolve("worker")); + Files.writeString(session.browserProfileDirectory().resolve("worker").resolve("Cookies"), "session"); + AgentCoordination.finish(project, session.runId(), null, "STOPPED", Instant.now().minus(Duration.ofHours(2)), null); + Files.writeString(session.sessionStateFile(), "{\"pid\":1}\n"); + AgentCoordination.sweepRuns(project, Instant.now()); + assertTrue(Files.isRegularFile(session.browserProfileDirectory().resolve("worker").resolve("Cookies"))); + + AgentCoordination.Run shown = AgentCoordination.begin(project, request("run-window", "agent-window")); + Files.createDirectories(shown.browserProfileDirectory().resolve("worker")); + Files.writeString(shown.browserProfileDirectory().resolve("worker").resolve("Cookies"), "window"); + AgentCoordination.finish(project, shown.runId(), null, "STOPPED", Instant.now().minus(Duration.ofHours(2)), null); + tools.dscode.control.protocol.WindowDriver.show(project, shown.runId(), "agent-window", true); + AgentCoordination.sweepRuns(project, Instant.now()); + assertTrue(Files.isRegularFile(shown.browserProfileDirectory().resolve("worker").resolve("Cookies"))); + assertTrue(Files.isDirectory(shown.dataDirectory())); + } + + @Test + void browserProfileMayBeDeletedOnlyAfterStopWhenNothingHoldsTheRun() throws Exception { + AgentCoordination.Run run = AgentCoordination.begin(project, request("run-done", "agent-done")); + Path cookies = run.browserProfileDirectory().resolve("worker").resolve("Cookies"); + Files.createDirectories(cookies.getParent()); + Files.writeString(cookies, "done"); + Files.writeString(run.dataDirectory().resolve("record-extra.txt"), "dense"); + AgentCoordination.finish(project, run.runId(), null, "STOPPED", Instant.now().minus(Duration.ofHours(1)), null); + assertTrue(Files.isDirectory(run.dataDirectory())); + assertTrue(Files.isRegularFile(cookies)); + assertTrue(Files.isRegularFile(AgentCoordination.logFile(project))); + + AgentCoordination.RunSweep sweep = AgentCoordination.sweepRuns(project, Instant.now()); + assertEquals(1, sweep.profilesRemoved()); + assertFalse(Files.exists(cookies)); + assertTrue(Files.isRegularFile(run.recordFile())); + assertTrue(Files.isDirectory(run.dataDirectory())); + assertEquals(0, AgentCoordination.sweepRuns(project, Instant.now()).profilesRemoved()); + } + + @Test + void finishDoesNotDeleteTheRunDirectory() throws Exception { + AgentCoordination.Run run = AgentCoordination.begin(project, request("run-finish-keeps", "agent-keep")); + Path cookies = run.browserProfileDirectory().resolve("worker").resolve("Cookies"); + Files.createDirectories(cookies.getParent()); + Files.writeString(cookies, "stay"); + Files.writeString(run.sessionStateFile(), "{\"pid\":1}\n"); + AgentCoordination.finish(project, run.runId(), "learned the row", "STOPPED"); + assertTrue(Files.isDirectory(run.dataDirectory())); + assertTrue(Files.isRegularFile(cookies)); + assertTrue(Files.isRegularFile(run.sessionStateFile())); + assertTrue(Files.isRegularFile(run.recordFile())); + String record = Files.readString(run.recordFile()); + assertTrue(record.contains("stoppedAt")); + assertFalse(record.contains("\"stoppedAt\" : null")); + assertTrue(Files.readString(AgentCoordination.logFile(project)).contains("\tstop\t")); + } + + @Test + void expiredPayloadLeavesTheRecordSparseIndexAndRunProfile() throws Exception { + AgentCoordination.Run failed = AgentCoordination.begin(project, request("run-failed", "agent-gc")); + Files.writeString(failed.reportsDirectory().resolve("shot.png"), "png"); + Files.writeString(failed.diagnosticDirectory().resolve("run-index.json"), "{\"runId\":\"run-failed\"}"); + Files.writeString(failed.diagnosticDirectory().resolve("events.jsonl"), "dense"); + Files.writeString(failed.dataDirectory().resolve("pkb_run_profile"), "pkb_browser=CHROME_HEADLESS"); + AgentCoordination.finish(project, failed.runId(), null, "FAILED", Instant.parse("2020-01-01T00:00:00Z"), null); + + AgentCoordination.Run old = AgentCoordination.begin(project, request("run-old", "agent-gc")); + Files.writeString(old.reportsDirectory().resolve("shot.png"), "png"); + Files.writeString(old.scratchDirectory().resolve("tmp.txt"), "tmp"); + Files.writeString(old.diagnosticDirectory().resolve("run-index.json"), "{\"runId\":\"run-old\"}"); + Files.writeString(old.diagnosticDirectory().resolve("summary.json"), "{}"); + Files.writeString(old.diagnosticDirectory().resolve("events.jsonl"), "dense-events"); + Files.writeString(old.dataDirectory().resolve("pkb_run_profile"), "pkb_browser=EDGE"); + AgentCoordination.finish(project, old.runId(), null, "STOPPED", Instant.parse("2020-01-02T00:00:00Z"), null); + + AgentCoordination.sweepRuns(project, Instant.parse("2026-10-05T00:00:00Z")); + AgentCoordination.sweepRuns(project, Instant.parse("2026-10-05T00:00:00Z")); + assertTrue(Files.isRegularFile(failed.reportsDirectory().resolve("shot.png"))); + assertTrue(Files.isRegularFile(failed.recordFile())); + assertFalse(Files.exists(old.reportsDirectory().resolve("shot.png"))); + assertFalse(Files.exists(old.scratchDirectory().resolve("tmp.txt"))); + assertFalse(Files.exists(old.diagnosticDirectory().resolve("events.jsonl"))); + assertTrue(Files.isRegularFile(old.recordFile())); + assertTrue(Files.isRegularFile(old.diagnosticDirectory().resolve("run-index.json"))); + assertTrue(Files.isRegularFile(old.diagnosticDirectory().resolve("summary.json"))); + assertTrue(Files.isRegularFile(old.dataDirectory().resolve("pkb_run_profile"))); + assertTrue(Files.isDirectory(old.dataDirectory())); + } + private void captureProfile(List profiles, CountDownLatch ready, CountDownLatch go) { ready.countDown(); try { diff --git a/src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java b/src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java index ab626f62d..4ce740457 100644 --- a/src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java +++ b/src/test/java/tools/dscode/control/protocol/PickleballLocalLayoutTest.java @@ -5,6 +5,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -61,7 +62,7 @@ void completeCurrentJsonSelectsVersionedWorkbenchEvenWhenLegacyExists() throws E PickleballLocalLayout.workbenchStateRoot(tempDir) ); assertEquals( - pickleball.resolve("v/2.1.11/investigations"), + pickleball.resolve("investigations"), PickleballLocalLayout.investigationsDirectory(tempDir) ); assertEquals( @@ -79,4 +80,40 @@ void findProjectRootWalksUpFromNestedOpenScripts() throws Exception { assertTrue(PickleballLocalLayout.looksLikeProject(tempDir)); assertTrue(PickleballLocalLayout.isPickleballDirectory(tempDir.resolve(".pickleball"))); } + + @Test + void newInvestigationsWriteToTheRootAndLegacyVersionedIdsStayReadable() throws Exception { + Path pickleball = tempDir.resolve(".pickleball"); + PickleballLocalLayout.writeCurrent( + pickleball, + PickleballLocalLayout.CurrentPointer.completeNow("2.1.14") + ); + Path legacy = pickleball.resolve("v/2.1.11/investigations/legacy-id"); + Files.createDirectories(legacy); + Files.writeString(legacy.resolve("investigation.json"), "{\"pkb_investigation_id\":\"legacy-id\"}\n"); + Files.writeString(legacy.resolve("report.html"), "

legacy

\n"); + + InvestigationHandoff.EmitResult emitted = InvestigationHandoff.emit(tempDir, Map.of( + "pkb_investigation_id", "new-id", + "cause", "the row was empty", + "fix", "not fixed" + )); + assertEquals(".pickleball/investigations/new-id/report.html", emitted.reportPath()); + assertTrue(Files.isRegularFile(pickleball.resolve("investigations/new-id/investigation.json"))); + assertFalse(Files.exists(pickleball.resolve("v/2.1.14/investigations/new-id"))); + + assertEquals(pickleball.resolve("investigations/new-id"), InvestigationHandoff.directoryFor(tempDir, "new-id")); + assertEquals(legacy, InvestigationHandoff.directoryFor(tempDir, "legacy-id")); + assertTrue(InvestigationHandoff.readJson(tempDir, "legacy-id").orElseThrow().contains("legacy-id")); + assertTrue(InvestigationHandoff.reportFiles(tempDir).stream() + .anyMatch(path -> path.toString().replace('\\', '/').contains("v/2.1.11/investigations/legacy-id/report.html"))); + + Path rootInvestigations = pickleball.resolve("investigations"); + Path versioned = pickleball.resolve("v/2.1.11/investigations"); + assertTrue(InvestigationHandoff.isInvestigationsPath(pickleball, rootInvestigations)); + assertTrue(InvestigationHandoff.isInvestigationsPath(pickleball, rootInvestigations.resolve("new-id/investigation.json"))); + assertTrue(InvestigationHandoff.isInvestigationsPath(pickleball, versioned)); + assertTrue(InvestigationHandoff.isInvestigationsPath(pickleball, versioned.resolve("legacy-id/report.html"))); + assertFalse(InvestigationHandoff.isInvestigationsPath(pickleball, pickleball.resolve("runs/run-1"))); + } } diff --git a/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java b/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java index 5bec15711..26d20feb2 100644 --- a/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java +++ b/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java @@ -81,4 +81,39 @@ void openScriptsDoNotBakeVersionOrM2Path() { assertTrue(cmd.contains("PROJECT:~-1")); assertTrue(ps.contains("SNAPSHOT")); } + + @Test + void exportGuidanceDoesNotDeleteEitherInvestigationsTree() throws Exception { + Path project = Files.createTempDirectory(tempDir, "consumer"); + Path pickleball = project.resolve(".pickleball"); + String version = PickleballVersion.running(PickleballLocalStore.class); + Path versionRoot = pickleball.resolve("v").resolve(version); + Path rootInvestigation = pickleball.resolve("investigations/keep-root"); + Path versioned = versionRoot.resolve("investigations/keep-version"); + Path other = pickleball.resolve("v/9.9.9/investigations/keep-other"); + Files.createDirectories(rootInvestigation); + Files.createDirectories(versioned); + Files.createDirectories(other); + Files.writeString(rootInvestigation.resolve("investigation.json"), "{\"id\":\"root\"}\n"); + Files.writeString(rootInvestigation.resolve("report.html"), "

root

\n"); + Files.writeString(versioned.resolve("investigation.json"), "{\"id\":\"version\"}\n"); + Files.writeString(versioned.resolve("report.html"), "

version

\n"); + Files.writeString(other.resolve("investigation.json"), "{\"id\":\"other\"}\n"); + Files.writeString(versionRoot.resolve("GUIDANCE-MANIFEST.json"), """ + { + "files": [ + "investigations/keep-version/investigation.json", + "investigations/keep-version/report.html", + "AGENT-GUIDE.md" + ] + } + """); + + assertEquals(0, PickleballLocalStore.exportGuidance(pickleball, System.out, System.err)); + assertTrue(Files.isRegularFile(rootInvestigation.resolve("investigation.json"))); + assertTrue(Files.isRegularFile(rootInvestigation.resolve("report.html"))); + assertTrue(Files.isRegularFile(versioned.resolve("investigation.json"))); + assertTrue(Files.isRegularFile(versioned.resolve("report.html"))); + assertTrue(Files.isRegularFile(other.resolve("investigation.json"))); + } } diff --git a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java index 450a872e8..983913b6a 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java @@ -299,6 +299,34 @@ void shortLogNoteInboxAndFinishUseTheProjectBoard() throws Exception { assertTrue(board.contains("\tstart\t")); assertTrue(board.contains("\tstop\t")); assertTrue(board.contains("record.json")); + assertTrue(Files.isDirectory(AgentCoordination.runDirectory(tempDir, "run-finish"))); + } + + @Test + void boardCommandsDoNotConsumeAPostAndSweepOnlyExpiredRows() throws Exception { + Output presence = run("presence", tempDir.toString(), "--touch", "--agent=agent-board", "--run-id=run-board"); + assertEquals(0, presence.exitCode(), presence.stderr()); + assertTrue(Files.isRegularFile(tempDir.resolve(".pickleball/presence/agent-board.json"))); + + Output post = run( + "post", tempDir.toString(), "--write", "--to=all", "--from=agent-board", + "--text=I have the window", "--ttl=1h" + ); + assertEquals(0, post.exitCode(), post.stderr()); + Output listed = run("post", tempDir.toString(), "--list"); + assertTrue(listed.stdout().contains("I have the window")); + Output taken = run("inbox", tempDir.toString(), "--take", "--agent=agent-board"); + assertTrue(taken.stdout().contains("(no inbox notes)")); + assertTrue(run("post", tempDir.toString(), "--list").stdout().contains("I have the window")); + + Output history = run("history", tempDir.toString(), "--append", "--text=2.1.14 files fix branch commit merge run-board"); + assertEquals(0, history.exitCode(), history.stderr()); + assertTrue(run("history", tempDir.toString(), "--tail").stdout().contains("run-board")); + + Output sweep = run("gc-runs", tempDir.toString()); + assertEquals(0, sweep.exitCode(), sweep.stderr()); + assertTrue(Files.isRegularFile(tempDir.resolve(".pickleball/presence/agent-board.json"))); + assertTrue(Files.isRegularFile(tempDir.resolve(".pickleball/history.log"))); } private Output run(String... args) { diff --git a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java index bdb0ce3ec..c9c5874d4 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java @@ -128,6 +128,10 @@ void coordinationFlagsAreNotForwardedAndDoNotJoinTheName() { assertTrue(WorkbenchCommandLine.isAgentCoreCommand("note")); assertTrue(WorkbenchCommandLine.isAgentCoreCommand("inbox")); assertTrue(WorkbenchCommandLine.isAgentCoreCommand("finish")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("presence")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("post")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("history")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("gc-runs")); } @Test From 2dd656824c76d0b03ba3691fb5dffc0eeb9a2e14 Mon Sep 17 00:00:00 2001 From: Dan Shell Date: Tue, 6 Oct 2026 18:03:53 +0000 Subject: [PATCH 23/23] Bottom line: use-version switches an already exported tree without deleting the one left behind, gc-versions drops only a version unused for 3 days, and the last failed run includes a stopped run that failed. --- docs/agent/feature-map.md | 2 +- docs/agent/repository-index.md | 6 + docs/configuration.md | 6 +- docs/consumer-agent-guide.md | 15 +- docs/consumer-project.md | 2 +- docs/diagnostic-reporting.md | 2 +- docs/pickleball-workbench.md | 2 +- .../protocol/PickleballLocalLayout.java | 34 ++ .../protocol/PickleballLocalStore.java | 92 +++++ scripts/refresh_agent_index.py | 5 + .../coordination/AgentCoordination.java | 335 +++++++++++++++--- .../launcher/WorkbenchAgentCommands.java | 20 ++ .../dscode/launcher/WorkbenchCommandLine.java | 24 +- .../pickleball/guidance/AGENT-GUIDE.md | 15 +- .../guidance/docs/agent/feature-map.md | 2 +- .../guidance/docs/agent/repository-index.md | 6 + .../pickleball/guidance/docs/configuration.md | 6 +- .../guidance/docs/consumer-agent-guide.md | 15 +- .../guidance/docs/consumer-project.md | 2 +- .../guidance/docs/diagnostic-reporting.md | 2 +- .../guidance/docs/pickleball-workbench.md | 2 +- .../coordination/AgentCoordinationTest.java | 54 +++ .../protocol/PickleballLocalStoreTest.java | 63 ++++ .../dscode/launcher/VersionSwitchTest.java | 171 +++++++++ .../launcher/WorkbenchCommandLineTest.java | 7 + 25 files changed, 813 insertions(+), 77 deletions(-) create mode 100644 src/test/java/tools/dscode/launcher/VersionSwitchTest.java diff --git a/docs/agent/feature-map.md b/docs/agent/feature-map.md index 407ae37c7..83014ac2b 100644 --- a/docs/agent/feature-map.md +++ b/docs/agent/feature-map.md @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `ConfigTabModel`; `LongWork`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `ConfigTabModelTest`; `LongWorkTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `.pickleball/investigations/`; `.pickleball/presence/`; `.pickleball/posts/`; `.pickleball/history.log`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `.pickleball/investigations/`; `.pickleball/presence/`; `.pickleball/posts/`; `.pickleball/history.log`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `gc-versions`, `use-version`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | diff --git a/docs/agent/repository-index.md b/docs/agent/repository-index.md index 85ca72f28..7c9d9bf64 100644 --- a/docs/agent/repository-index.md +++ b/docs/agent/repository-index.md @@ -424,6 +424,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballVersionTest.java` - `src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java` +- `src/test/java/tools/dscode/launcher/VersionSwitchTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java` @@ -822,10 +823,13 @@ This inventory helps coding agents discover relevant files. It does not replace ## .pickleball owners Jar cache is regenerable under `v//`, `open/`, and `current.json`. +Side-by-side `v//` trees stay until an explicit `gc-versions`. +`use-version` points `current.json` at an already exported tree and does not delete the others. Project history stays at the `.pickleball` root and survives export-guidance. Legacy `.pickleball/workbench/` is only the fallback when `current.json` is missing or not usable. - `.pickleball/v//` +- `.pickleball/v//.last-used` - `.pickleball/open/` - `.pickleball/current.json` - `.pickleball/runs//` @@ -848,3 +852,5 @@ Launcher verbs, same exec.args style as inbox, note, and short-log: - `history --append` - `history --tail` - `gc-runs` +- `gc-versions` +- `use-version --version=` diff --git a/docs/configuration.md b/docs/configuration.md index 6b2151356..aa9fdd2bc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,13 +126,13 @@ The short log is `.pickleball/agent-log`. Read it before a run, after a run, and `inbox//` is directed mail for that agent. `take` deletes that note. `list` does not. `inbox/any` is not first-taker-wins. A note to every agent is a post, not a consumed inbox file. The inbox is not a second log and not an assignment framework. -The board is three files, not more inbox folders. `.pickleball/presence/.json` is a heartbeat (`agent id`, `lastSeen`, current run ids). An active agent may drop a row older than 15 minutes. That drop removes the row only. `.pickleball/posts/.json` is a hint (`id`, `from`, `to` an agent id or `all`, optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, optional `ackedBy`). Default expiry is 24 hours from the last update. One hour is appropriate for "I have the window" and is not the default. List does not delete. Ack is optional. `.pickleball/history.log` is an append-only summation (version, files, fix, branch, commit, merge, run id), trimmed from the front above 10 MB. It is not a mailbox. Agents are not daemons. Launcher commands are one-shot: `presence --touch|--list|--sweep`, `post --write|--list|--renew|--sweep`, `history --append|--tail`, and `gc-runs`. +The board is three files, not more inbox folders. `.pickleball/presence/.json` is a heartbeat (`agent id`, `lastSeen`, current run ids). An active agent may drop a row older than 15 minutes. That drop removes the row only. `.pickleball/posts/.json` is a hint (`id`, `from`, `to` an agent id or `all`, optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, optional `ackedBy`). Default expiry is 24 hours from the last update. One hour is appropriate for "I have the window" and is not the default. List does not delete. Ack is optional. `.pickleball/history.log` is an append-only summation (version, files, fix, branch, commit, merge, run id), trimmed from the front above 10 MB. It is not a mailbox. Agents are not daemons. Launcher commands are one-shot: `presence --touch|--list|--sweep`, `post --write|--list|--renew|--sweep`, `history --append|--tail`, `gc-runs`, `gc-versions`, and `use-version --version=`. -`browser-profile/` under a run is deleted only by `gc-runs`, and only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Remote drivers and a consumer-supplied `user-data-dir` are not this directory. Dense evidence stays for the run open in Workbench and for the last failed run. Other stopped payloads may go 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile`. Never delete a run with no stop line. `finish` and `stop` only write `stoppedAt` and a stop line. +`browser-profile/` under a run is deleted only by `gc-runs`, and only when that run has a stop line, nothing has the profile open, and no agent holds the run. Chrome `SingletonLock` and an Edge `lockfile` both mean the profile is open. Not when Cucumber returns, and not on finish alone. Remote drivers and a consumer-supplied `user-data-dir` are not this directory. Dense evidence stays for the run open in Workbench and for the last failed run. The last failed run is the latest stopped run whose status is FAILED, or a STOPPED record with a failure count, a failed-scenario flag, or a non-empty failure summary. `finish` writes FAILED when that run had failures and STOPPED only when it did not. Other stopped payloads may go 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile`. Never delete a run with no stop line. `finish` and `stop` only write `stoppedAt` and a stop line. `current.json` is a version pointer (`pickleballVersion`, `complete`, `updatedAt`), not a path to the dependency jar. `export-guidance` of version X writes `v/X`, records `v/X/.last-used`, and points `current.json` at X. It leaves every other `v//` tree on disk. `use-version --version=` does the same pointer move when that tree is already a complete export, and refreshes the root aliases and `open/` from that tree. It does not invent a missing export and does not delete the tree being left. Session, attach, and last-discover stay under `v//workbench` for the version that owns them. A run records the version it started with. Moving the pointer does not retarget a RUNNING run. `gc-versions` is explicit. It is not run from export, use-version, or finish. It deletes a version tree only when it is not the usable current pointer, last use is at least 3 days ago, no RUNNING run started with it, and its workbench has no live session, attach, or worker. A missing `.last-used` counts as last used now. An unused version tree is expired, not stale. Stale still means an untrusted export. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, the profile, the session file, or `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. -Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, `presence`, `post`, `history`, `gc-runs`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. They do not delete the run. +Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, `presence`, `post`, `history`, `gc-runs`, `gc-versions`, `use-version --version=`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. They do not delete the run. ## Named profiles diff --git a/docs/consumer-agent-guide.md b/docs/consumer-agent-guide.md index a9e59ff37..63ef4a5f0 100644 --- a/docs/consumer-agent-guide.md +++ b/docs/consumer-agent-guide.md @@ -20,7 +20,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `gc-versions`, `use-version`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. @@ -39,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Chrome `SingletonLock` and an Edge `lockfile` both mean the profile is open. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. The last failed run is the latest stopped run whose status is FAILED, or a STOPPED record with a failure count, a failed-scenario flag, or a non-empty failure summary. `finish` writes FAILED when that run had failures and STOPPED only when it did not. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. One window has one driver. Each agent uses its own run id. Launcher commands are one-shot and exit. A 10-minute presence sweep is optional and cannot be enforced. @@ -72,9 +72,13 @@ Project history. It must survive `export-guidance` and a version bump. It stays - `investigations/` - `presence/`, `posts/`, `history.log` -`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. New session, attach, last-discover, logs, and `live/classes` stay under `v//workbench` when `current.json` is complete. Do not describe both layouts as current. +`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. Session, attach, and last-discover stay under `v//workbench` for the version that owns them. When `current.json` is complete, the next launch uses `v//workbench`. Switching the pointer does not destroy the other version's workbench tree. Do not describe both layouts as current. -Export cleanup is a manifest diff of generated files in that version folder. There is no general `.pickleball` garbage collector. Do not delete other `v//` trees until the new `current.json` is complete and a launch has succeeded. Keeping the previous version tree is enough. Never automatically delete `current.json`, `open/`, or the `v//` tree named by a complete pointer, or an in-progress export (`complete: false`). +Export cleanup is a manifest diff of generated files in that version folder. `export-guidance` and `use-version` do not delete any other `v//` tree. `gc-versions` is the explicit version sweep. It is not a daemon, and it is not run from `export-guidance`, `use-version`, or finish. It may delete `v//` only when that version is not named by a usable `current.json`, its last use is at least 3 days ago, no RUNNING run was started with it, and that version's workbench tree has no live session, attach, or worker. A missing or unreadable `v//.last-used` counts as last used now, so an old tree is not deleted on the first sweep. Deleting the tree removes that version's guidance, controller, libs, and workbench state only. It does not delete `runs/`, `investigations/` (including a legacy `v//investigations` tree), `presence/`, `posts/`, `inbox/`, `agent-log`, or `history.log`. Two sweepers deleting the same expired tree must be safe. Never delete `current.json`, `open/`, or the `v//` tree named by a usable pointer. + +A version tree is last used when `export-guidance` completes it or `use-version` points `current.json` at it. That time is `v//.last-used`. A switch in the same session updates `.last-used`, so the tree cannot expire while it is being tested. An unused version tree is expired, not stale. + +`use-version --version=` switches the pointer to an already complete `v//` export. It rewrites `current.json`, refreshes root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json`, and refreshes `open/` from that tree. It does not re-copy a sibling version and does not delete the tree being left. If that version tree is missing or not a complete export, it prints that `export-guidance` from that dependency is required and leaves `current.json` unchanged. The last `use-version` or `export-guidance` wins the pointer. Switching the pointer does not stop a run, delete a run, delete a browser profile, or clear presence, posts, inbox, investigations, or `history.log`. A run records the version it started with and is not retargeted when the pointer moves. An agent switches versions in one session with `use-version`, or with `export-guidance` from the dependency it wants. New investigation handoffs always go to `.pickleball/investigations//`. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read the legacy versioned path. `export-guidance` leaves both trees alone. @@ -122,12 +126,13 @@ A successful `export-guidance .pickleball` run: - copies root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` as aliases of that version so the well-known agent pointer stays stable; - writes relocatable Workbench openers under `.pickleball/open/`; - writes `.pickleball/current.json` last (`pickleballVersion`, `complete`, `updatedAt`) — a version pointer, not a path to the dependency jar; +- records last use on `v//.last-used`; - removes files managed by the previous manifest that are no longer shipped in that version folder, while leaving unrelated files alone, including `.pickleball/investigations/` and any other `v//` trees; and - best-effort ensures `.pickleball` is ignored by Git, preferring an existing `.gitignore` and then repository-local `.git/info/exclude`. Any Pickleball execution (`PickleballRunner`, `java -jar pickleball.jar`, Workbench launcher) lazily materializes the running jar's version folder if it is missing. It unpacks from the running jar; it never copies a sibling version folder. Direct `java -jar pickleball-X.jar` pins `current.json` to X. Open scripts set `PKB_OPEN_BOOTSTRAP=1` so Java hops to the pinned jar when `current.json` is complete. -The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. The manifest records the last completed export; it is not a substitute for rerunning the exporter. +The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. It does not mean an old version tree. An unused version tree is expired, not stale. An agent switches versions in one session with `use-version --version=`, or with `export-guidance` from the dependency it wants. The manifest records the last completed export; it is not a substitute for rerunning the exporter. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Version folders keep each export isolated. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. diff --git a/docs/consumer-project.md b/docs/consumer-project.md index 10ad41712..353236ba4 100644 --- a/docs/consumer-project.md +++ b/docs/consumer-project.md @@ -26,7 +26,7 @@ Then read or browse: .pickleball/v//maven-consumer-project/ ``` -Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current version so the well-known agent pointer stays stable. Canonical files live under `v//`. Rerun export before Pickleball work even when `.pickleball` already exists. A successful export overwrites current managed files, removes obsolete previously managed files from that version folder, writes `current.json` last with `complete: true`, and best-effort keeps `.pickleball` ignored by Git. If export fails, treat existing generated guidance as potentially stale. +Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current version so the well-known agent pointer stays stable. Canonical files live under `v//`. Rerun export before Pickleball work even when `.pickleball` already exists. A successful export overwrites current managed files, removes obsolete previously managed files from that version folder, writes `v//.last-used`, writes `current.json` last with `complete: true`, and best-effort keeps `.pickleball` ignored by Git. `export-guidance` and `use-version` leave every other version tree in place. `use-version --version=` points `current.json` at an already complete export and refreshes the root aliases and `open/` from that tree. A missing export is not invented; run `export-guidance` from that dependency instead. `gc-versions` is the explicit sweep for a tree last used at least 3 days ago. It is not part of export or of switching. If export fails, treat existing generated guidance as potentially stale. Stale means an untrusted export, not an old version tree. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. diff --git a/docs/diagnostic-reporting.md b/docs/diagnostic-reporting.md index b0926184c..97425fc17 100644 --- a/docs/diagnostic-reporting.md +++ b/docs/diagnostic-reporting.md @@ -183,7 +183,7 @@ links[] # optional workbench_go / wb:// targets Do not parse Gherkin in the Workbench controller to invent `executionMap`. If the agent omitted it, derive it from `events.jsonl` at emit. v1 JSON still renders. Workbench Report is the same JSON. -`export-guidance` does not manage or delete `.pickleball/investigations/` or any legacy `v//investigations/` tree. +`export-guidance` does not manage or delete `.pickleball/investigations/` or any legacy `v//investigations/` tree. `gc-versions` removes an expired version's guidance and workbench state only, and leaves that investigations tree in place. ## Outcomes and completion diff --git a/docs/pickleball-workbench.md b/docs/pickleball-workbench.md index b15f9af06..903478bf0 100644 --- a/docs/pickleball-workbench.md +++ b/docs/pickleball-workbench.md @@ -132,7 +132,7 @@ Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` pri Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. -The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. +The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. `use-version --version=` points `current.json` at an already exported tree without deleting the others. `gc-versions` is the explicit sweep for a tree unused for 3 days. It is not run from export or from switching. Gradle consumers can expose the same dependency-owned launcher without resolving a cache path or adding a Workbench dependency: diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java index 546baeb13..125fe83d5 100644 --- a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalLayout.java @@ -29,6 +29,7 @@ public final class PickleballLocalLayout { public static final String LAST_DISCOVER_FILE = "last-discover.json"; public static final String CLI_SESSION_FILE = "cli-session.json"; public static final String ATTACH_FILE = "attach.json"; + public static final String LAST_USED_FILE = ".last-used"; public static final String MATERIALIZE_LOCK = ".materialize.lock"; public static final String OPEN_SCRIPT_UNIX = "pickleball-workbench.sh"; public static final String OPEN_SCRIPT_CMD = "pickleball-workbench.cmd"; @@ -190,6 +191,39 @@ public static void writeCurrent(Path pickleballRoot, CurrentPointer pointer) thr } } + public static void writeLastUsed(Path versionRoot, Instant when) throws IOException { + if (versionRoot == null) { + throw new IllegalArgumentException("Version tree is required."); + } + Instant clock = when == null ? Instant.now() : when; + Path root = versionRoot.toAbsolutePath().normalize(); + Files.createDirectories(root); + Path target = root.resolve(LAST_USED_FILE); + Path temporary = root.resolve(LAST_USED_FILE + ".tmp"); + Files.writeString(temporary, clock.toString() + "\n", StandardCharsets.UTF_8); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** Empty when the file is missing or not an instant. Callers treat that as last used now. */ + public static Optional readLastUsed(Path versionRoot) { + if (versionRoot == null) return Optional.empty(); + Path file = versionRoot.toAbsolutePath().normalize().resolve(LAST_USED_FILE); + if (!Files.isRegularFile(file)) return Optional.empty(); + try { + String text = Files.readString(file, StandardCharsets.UTF_8).trim(); + if (text.isEmpty()) return Optional.empty(); + String first = text.lines().findFirst().orElse("").trim(); + if (first.isEmpty()) return Optional.empty(); + return Optional.of(Instant.parse(first)); + } catch (Exception ignored) { + return Optional.empty(); + } + } + public static boolean isSafeVersion(String version) { if (version == null || version.isBlank()) return false; String value = version.trim(); diff --git a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java index c7483fce8..fa7cd5bba 100644 --- a/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java +++ b/pickleball-control-protocol/src/main/java/tools/dscode/control/protocol/PickleballLocalStore.java @@ -142,9 +142,101 @@ private static void writeVersionedTree( pickleball.resolve(PickleballLocalLayout.GUIDANCE_MANIFEST), StandardCopyOption.REPLACE_EXISTING ); + snapshotOpenScripts(pickleball, versionRoot); + PickleballLocalLayout.writeLastUsed(versionRoot, Instant.now()); PickleballLocalLayout.writeCurrent(pickleball, PickleballLocalLayout.CurrentPointer.completeNow(version)); } + /** + * Point {@code current.json} at an already complete {@code v//} export. + * Does not copy a sibling version, does not delete the tree being left, and + * does not invent files when the export is missing. + */ + public static int useVersion(Path projectRoot, String version, PrintStream out, PrintStream err) throws IOException { + if (!PickleballLocalLayout.isSafeVersion(version)) { + err.println("use-version requires --version=."); + return 2; + } + Path project = projectRoot == null + ? Path.of("").toAbsolutePath().normalize() + : projectRoot.toAbsolutePath().normalize(); + Path pickleball = PickleballLocalLayout.isPickleballDirectory(project) + ? project + : PickleballLocalLayout.root(project); + if (!completeExport(pickleball, version)) { + err.println("export-guidance from Pickleball " + version + " is required. current.json was not changed."); + return 1; + } + Path versionRoot = PickleballLocalLayout.versionRoot(pickleball, version); + Files.copy( + versionRoot.resolve(PickleballLocalLayout.AGENT_GUIDE), + pickleball.resolve(PickleballLocalLayout.AGENT_GUIDE), + StandardCopyOption.REPLACE_EXISTING + ); + Files.copy( + versionRoot.resolve(PickleballLocalLayout.GUIDANCE_MANIFEST), + pickleball.resolve(PickleballLocalLayout.GUIDANCE_MANIFEST), + StandardCopyOption.REPLACE_EXISTING + ); + refreshOpenFromVersion(pickleball, versionRoot); + PickleballLocalLayout.writeLastUsed(versionRoot, Instant.now()); + PickleballLocalLayout.writeCurrent(pickleball, PickleballLocalLayout.CurrentPointer.completeNow(version)); + out.println("current=" + version); + out.println("last-used=" + versionRoot.resolve(PickleballLocalLayout.LAST_USED_FILE)); + return 0; + } + + static boolean completeExport(Path pickleball, String version) { + if (!PickleballLocalLayout.isSafeVersion(version)) return false; + Path root = PickleballLocalLayout.versionRoot(pickleball, version); + if (!Files.isRegularFile(root.resolve(PickleballLocalLayout.AGENT_GUIDE))) return false; + Path manifest = root.resolve(PickleballLocalLayout.GUIDANCE_MANIFEST); + if (!Files.isRegularFile(manifest)) return false; + try { + String json = Files.readString(manifest, StandardCharsets.UTF_8); + Matcher matcher = Pattern.compile("\"pickleballVersion\"\\s*:\\s*\"([^\"]*)\"").matcher(json); + return matcher.find() && version.equals(matcher.group(1)); + } catch (IOException ignored) { + return false; + } + } + + private static void snapshotOpenScripts(Path pickleball, Path versionRoot) throws IOException { + Path from = pickleball.resolve(PickleballLocalLayout.OPEN_DIRECTORY); + if (!Files.isDirectory(from)) return; + Path to = versionRoot.resolve(PickleballLocalLayout.OPEN_DIRECTORY); + Files.createDirectories(to); + try (var files = Files.list(from)) { + for (Path file : files.filter(Files::isRegularFile).toList()) { + Files.copy(file, to.resolve(file.getFileName()), StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private static void refreshOpenFromVersion(Path pickleball, Path versionRoot) throws IOException { + Path from = versionRoot.resolve(PickleballLocalLayout.OPEN_DIRECTORY); + Path script = from.resolve(PickleballLocalLayout.OPEN_SCRIPT_UNIX); + if (!Files.isRegularFile(script)) { + writeOpenScripts(pickleball); + return; + } + Path open = pickleball.resolve(PickleballLocalLayout.OPEN_DIRECTORY); + Files.createDirectories(open); + try (var files = Files.list(from)) { + for (Path file : files.filter(Files::isRegularFile).toList()) { + Path target = open.resolve(file.getFileName()); + Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING); + if (PickleballLocalLayout.OPEN_SCRIPT_UNIX.equals(file.getFileName().toString())) { + try { + Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException ignored) { + // Windows. + } + } + } + } + } + private static void exportFlat(Path root, PrintStream out, PrintStream err) throws IOException { List files = guidanceFiles(); cleanupPreviousGuidance(root, files, err); diff --git a/scripts/refresh_agent_index.py b/scripts/refresh_agent_index.py index da1fdf7eb..851cfb089 100644 --- a/scripts/refresh_agent_index.py +++ b/scripts/refresh_agent_index.py @@ -117,6 +117,7 @@ BOARD_PATHS = ( ".pickleball/v//", + ".pickleball/v//.last-used", ".pickleball/open/", ".pickleball/current.json", ".pickleball/runs//", @@ -138,6 +139,8 @@ "history --append", "history --tail", "gc-runs", + "gc-versions", + "use-version --version=", ) @@ -206,6 +209,8 @@ def render() -> str: "## .pickleball owners", "", "Jar cache is regenerable under `v//`, `open/`, and `current.json`.", + "Side-by-side `v//` trees stay until an explicit `gc-versions`.", + "`use-version` points `current.json` at an already exported tree and does not delete the others.", "Project history stays at the `.pickleball` root and survives export-guidance.", "Legacy `.pickleball/workbench/` is only the fallback when `current.json` is missing or not usable.", "", diff --git a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java index d0a015664..1141b2b5a 100644 --- a/src/main/java/tools/dscode/common/coordination/AgentCoordination.java +++ b/src/main/java/tools/dscode/common/coordination/AgentCoordination.java @@ -1,10 +1,12 @@ package tools.dscode.common.coordination; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import tools.dscode.control.protocol.ControlProtocol; import tools.dscode.control.protocol.PickleballLocalLayout; +import tools.dscode.control.protocol.PickleballVersion; import tools.dscode.control.protocol.WindowDriver; import tools.dscode.testengine.PKB_props; import tools.dscode.testengine.PickleballRunner; @@ -56,6 +58,7 @@ public final class AgentCoordination { public static final String RECORD_FILE = "record.json"; public static final String RUN_PROFILE_FILE = "pkb_run_profile"; public static final Duration RETENTION = Duration.ofDays(3); + public static final Duration VERSION_UNUSED = Duration.ofDays(3); public static final Duration PRESENCE_STALE = Duration.ofMinutes(15); public static final Duration POST_TTL = Duration.ofHours(24); public static final Duration RUN_PAYLOAD_STALE = Duration.ofHours(24); @@ -156,7 +159,11 @@ public record RunRecord( String startedAt, String stoppedAt, String status, - String dataPath + String dataPath, + String pickleballVersion, + Integer failureCount, + Boolean failedScenario, + String failureSummary ) { } @@ -300,7 +307,11 @@ public static Run begin(Path project, Request request, Instant when, Appendable clock.toString(), null, "RUNNING", - data.toString() + data.toString(), + startedVersion(project), + null, + null, + null ); writeRecord(recordFile, record); appendLog(project, clock, agentId, runId, "start", purpose, recordFile); @@ -363,22 +374,9 @@ public static void finish( boolean alreadyStopped = existing.stoppedAt() != null && !existing.stoppedAt().isBlank(); String learnedValue = blankToNull(learned); String keptLearned = learnedValue != null ? learnedValue : existing.learned(); - String statusValue = blankToNull(status); if (!alreadyStopped) { - if (statusValue == null) statusValue = "STOPPED"; - RunRecord updated = new RunRecord( - existing.runId(), - existing.agentId(), - existing.group(), - existing.sequence(), - existing.who(), - existing.why(), - keptLearned, - existing.startedAt(), - clock.toString(), - statusValue, - existing.dataPath() - ); + String statusValue = resolveStopStatus(status, existing, recordFile.getParent()); + RunRecord updated = withStop(existing, keptLearned, clock.toString(), statusValue); writeRecord(recordFile, updated); String purpose = oneLine(firstNonBlank(learnedValue, existing.why(), statusValue)); appendLog(project, clock, existing.agentId(), existing.runId(), "stop", purpose, recordFile); @@ -387,19 +385,7 @@ public static void finish( return; } if (learnedValue != null && !learnedValue.equals(existing.learned())) { - RunRecord updated = new RunRecord( - existing.runId(), - existing.agentId(), - existing.group(), - existing.sequence(), - existing.who(), - existing.why(), - learnedValue, - existing.startedAt(), - existing.stoppedAt(), - existing.status(), - existing.dataPath() - ); + RunRecord updated = withStop(existing, learnedValue, existing.stoppedAt(), existing.status()); writeRecord(recordFile, updated); appendLog( project, @@ -542,6 +528,9 @@ public record BoardPost( public record RunSweep(int profilesRemoved, int payloadsRemoved) { } + public record VersionSweep(int versionsRemoved) { + } + @JsonIgnoreProperties(ignoreUnknown = true) private record PresenceFile(String agentId, String lastSeen, List runIds) { } @@ -767,10 +756,13 @@ public static List tailHistory(Path project, int limit) throws IOExcepti /** * Explicit payload sweep. Not called from finish. Deletes a browser profile * only when that run has a stop line, nothing has the profile open, and no - * agent holds the run. Other dense payload waits 24 hours after stoppedAt, - * and never removes the run open in Workbench or the last failed run. - * record.json, sparse indexes, and pkb_run_profile stay. The run directory - * stays. Two sweepers deleting the same file are safe. + * agent holds the run. Chrome {@code SingletonLock} and an Edge {@code lockfile} + * both mean the profile is open. Other dense payload waits 24 hours after + * stoppedAt, and never removes the run open in Workbench or the last failed + * run. The last failed run is the latest stopped run whose status is FAILED, + * or a STOPPED record with a failure count, a failed-scenario flag, or a + * non-empty failure summary. record.json, sparse indexes, and pkb_run_profile + * stay. The run directory stays. Two sweepers deleting the same file are safe. */ public static RunSweep sweepRuns(Path project, Instant when) { Instant clock = when == null ? Instant.now() : when; @@ -796,6 +788,38 @@ public static RunSweep sweepRuns(Path project, Instant when) { return new RunSweep(profiles, payloads); } + /** + * Explicit version-tree sweep. Not called from export-guidance, use-version, + * or finish. A missing or unreadable {@code .last-used} counts as last used + * now. Deletes only an unused version's guidance, controller, libs, and + * workbench state. Project history and a legacy investigations tree stay. + * Two sweepers deleting the same expired tree are safe. + */ + public static VersionSweep sweepVersions(Path project, Instant when) { + Instant clock = when == null ? Instant.now() : when; + Path pickleball = PickleballLocalLayout.root(project); + Path versions = pickleball.resolve(PickleballLocalLayout.VERSIONS_DIRECTORY); + if (!Files.isDirectory(versions)) return new VersionSweep(0); + String current = usableCurrentVersion(pickleball); + Set running = runningVersions(project); + List children; + try (var listed = Files.list(versions)) { + children = listed.filter(Files::isDirectory).toList(); + } catch (IOException ignored) { + return new VersionSweep(0); + } + int removed = 0; + for (Path child : children) { + String name = child.getFileName() == null ? "" : child.getFileName().toString(); + if (!PickleballLocalLayout.isSafeVersion(name)) continue; + if (name.equals(current) || running.contains(name)) continue; + if (workbenchLive(child.resolve(PickleballLocalLayout.WORKBENCH_DIRECTORY))) continue; + if (!versionExpired(child, clock)) continue; + if (deleteVersionTree(pickleball, child)) removed++; + } + return new VersionSweep(removed); + } + public static List mavenProperties(Run run) { List properties = new ArrayList<>(); properties.add("-D" + PKB_props.PKB_RUN_ID + "=" + run.runId()); @@ -1245,10 +1269,7 @@ private static boolean profileOpen(Path directory) { Path profile = directory.resolve("browser-profile"); if (!Files.isDirectory(profile)) return false; try (var paths = Files.walk(profile)) { - return paths.anyMatch(path -> { - Path name = path.getFileName(); - return name != null && "SingletonLock".equals(name.toString()); - }); + return paths.anyMatch(AgentCoordination::isProfileLock); } catch (IOException ignored) { return true; } @@ -1275,10 +1296,8 @@ private static String lastFailedRun(List directories) { Instant latest = null; for (Path directory : directories) { RunRecord record = readRecordQuiet(directory.resolve(RECORD_FILE)); - if (record == null || record.status() == null || !"FAILED".equalsIgnoreCase(record.status().trim())) { - continue; - } - if (record.stoppedAt() == null || record.stoppedAt().isBlank()) continue; + if (record == null || record.stoppedAt() == null || record.stoppedAt().isBlank()) continue; + if (!failedRecord(record)) continue; Instant stopped; try { stopped = Instant.parse(record.stoppedAt().trim()); @@ -1302,6 +1321,238 @@ private static boolean payloadExpired(RunRecord record, Instant now) { } } + private static RunRecord withStop(RunRecord existing, String learned, String stoppedAt, String status) { + return new RunRecord( + existing.runId(), + existing.agentId(), + existing.group(), + existing.sequence(), + existing.who(), + existing.why(), + learned, + existing.startedAt(), + stoppedAt, + status, + existing.dataPath(), + existing.pickleballVersion(), + existing.failureCount(), + existing.failedScenario(), + existing.failureSummary() + ); + } + + private static String startedVersion(Path project) { + var current = PickleballLocalLayout.readCurrent(PickleballLocalLayout.root(project)); + if (current.isPresent() && current.get().usable()) { + return current.get().pickleballVersion(); + } + String running = PickleballVersion.running(AgentCoordination.class); + return PickleballLocalLayout.isSafeVersion(running) ? running : null; + } + + /** + * STOPPED and a null status become FAILED when the run already has a failure + * signal or the run directory shows failures. An explicit PASSED or FAILED + * from the caller is left alone. + */ + private static String resolveStopStatus(String requested, RunRecord existing, Path runDirectory) { + String statusValue = blankToNull(requested); + if (statusValue != null && !"STOPPED".equalsIgnoreCase(statusValue)) return statusValue; + boolean failed = recordSignalsFailure(existing) || directorySignalsFailure(runDirectory); + return failed ? "FAILED" : "STOPPED"; + } + + private static boolean failedRecord(RunRecord record) { + if (record.status() != null && "FAILED".equalsIgnoreCase(record.status().trim())) return true; + return record.status() != null + && "STOPPED".equalsIgnoreCase(record.status().trim()) + && recordSignalsFailure(record); + } + + private static boolean recordSignalsFailure(RunRecord record) { + if (record == null) return false; + if (record.failureCount() != null && record.failureCount() > 0) return true; + if (Boolean.TRUE.equals(record.failedScenario())) return true; + return record.failureSummary() != null && !record.failureSummary().isBlank(); + } + + private static boolean directorySignalsFailure(Path runDirectory) { + if (runDirectory == null || !Files.isDirectory(runDirectory)) return false; + try (var walk = Files.walk(runDirectory, 6)) { + for (Path path : walk.filter(Files::isRegularFile).toList()) { + if (skipFailureScan(runDirectory, path)) continue; + Path fileName = path.getFileName(); + if (fileName == null || !fileName.toString().endsWith(".json")) continue; + if (RECORD_FILE.equals(fileName.toString())) continue; + try { + if (Files.size(path) > 2_000_000L) continue; + if (jsonSignalsFailure(JSON.readTree(path.toFile()))) return true; + } catch (IOException ignored) { + // An unreadable pack is not a failure signal. + } + } + } catch (IOException ignored) { + return false; + } + return false; + } + + private static boolean skipFailureScan(Path runDirectory, Path path) { + Path relative; + try { + relative = runDirectory.relativize(path); + } catch (IllegalArgumentException ignored) { + return true; + } + if (relative.getNameCount() == 0) return true; + String first = relative.getName(0).toString(); + return "browser-profile".equals(first) || "config".equals(first) || "scratch".equals(first); + } + + private static boolean jsonSignalsFailure(JsonNode node) { + if (node == null || !node.isObject()) return false; + if ("FAILED".equalsIgnoreCase(text(node, "outcome"))) return true; + JsonNode failed = node.get("failed"); + if (failed != null && failed.isNumber() && failed.asInt(0) > 0) return true; + if (node.path("failedScenario").asBoolean(false) && node.has("failedScenario")) return true; + if (node.path("scenarioFailed").asBoolean(false) && node.has("scenarioFailed")) return true; + String summary = text(node, "failureSummary"); + if (summary != null && !summary.isBlank()) return true; + return jsonSignalsFailure(node.get("counts")) || jsonSignalsFailure(node.get("steps")); + } + + private static String text(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) return null; + return value.asText(null); + } + + private static String usableCurrentVersion(Path pickleball) { + var current = PickleballLocalLayout.readCurrent(pickleball); + if (current.isEmpty() || !current.get().usable()) return null; + return current.get().pickleballVersion(); + } + + private static Set runningVersions(Path project) { + Set versions = new LinkedHashSet<>(); + Path runs = PickleballLocalLayout.root(project).resolve(RUNS_DIRECTORY); + if (!Files.isDirectory(runs)) return versions; + List directories; + try (var children = Files.list(runs)) { + directories = children.filter(Files::isDirectory).toList(); + } catch (IOException ignored) { + return versions; + } + for (Path directory : directories) { + RunRecord record = readRecordQuiet(directory.resolve(RECORD_FILE)); + if (record == null || !runStillRunning(record)) continue; + String version = blankToNull(record.pickleballVersion()); + if (version != null && PickleballLocalLayout.isSafeVersion(version)) versions.add(version); + } + return versions; + } + + private static boolean runStillRunning(RunRecord record) { + if (record.stoppedAt() == null || record.stoppedAt().isBlank()) return true; + return record.status() != null && "RUNNING".equalsIgnoreCase(record.status().trim()); + } + + private static boolean versionExpired(Path versionRoot, Instant now) { + var lastUsed = PickleballLocalLayout.readLastUsed(versionRoot); + Instant used = lastUsed.orElse(now); + return !used.isAfter(now.minus(VERSION_UNUSED)); + } + + private static boolean workbenchLive(Path workbench) { + if (!Files.isDirectory(workbench)) return false; + if (sessionFileLive(workbench.resolve(PickleballLocalLayout.CLI_SESSION_FILE))) return true; + if (sessionFileLive(workbench.resolve(PickleballLocalLayout.ATTACH_FILE))) return true; + try (var walk = Files.walk(workbench)) { + for (Path path : walk.filter(Files::isRegularFile).toList()) { + Path name = path.getFileName(); + if (name == null) continue; + if ("worker.json".equals(name.toString())) return true; + if ("worker.pid".equals(name.toString()) && pidFileLive(path)) return true; + } + } catch (IOException ignored) { + return true; + } + return false; + } + + private static boolean sessionFileLive(Path file) { + if (!Files.isRegularFile(file)) return false; + try { + JsonNode node = JSON.readTree(file.toFile()); + JsonNode pidNode = node.get("pid"); + if (pidNode == null || pidNode.isNull() || !pidNode.isNumber()) return false; + long pid = pidNode.asLong(0L); + if (pid <= 0L) return false; + return ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(true); + } catch (Exception ignored) { + return true; + } + } + + private static boolean pidFileLive(Path file) { + try { + long pid = Long.parseLong(Files.readString(file, StandardCharsets.UTF_8).trim()); + if (pid <= 0L) return false; + return ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(true); + } catch (Exception ignored) { + return true; + } + } + + private static boolean deleteVersionTree(Path pickleball, Path versionDir) { + Path versions = pickleball.toAbsolutePath().normalize().resolve(PickleballLocalLayout.VERSIONS_DIRECTORY); + Path target = versionDir.toAbsolutePath().normalize(); + if (!target.startsWith(versions) || target.equals(versions)) return false; + Path realVersions; + Path realTarget; + try { + realVersions = versions.toRealPath(); + realTarget = target.toRealPath(); + } catch (IOException ignored) { + return false; + } + if (!realTarget.startsWith(realVersions) || realTarget.equals(realVersions)) return false; + Path investigations = target.resolve("investigations"); + boolean keepInvestigations = Files.isDirectory(investigations); + boolean removed = false; + List paths; + try (var walk = Files.walk(target)) { + paths = walk.sorted(Comparator.reverseOrder()).toList(); + } catch (IOException ignored) { + return false; + } + for (Path path : paths) { + if (path.equals(target)) continue; + if (keepInvestigations && (path.startsWith(investigations))) continue; + try { + if (Files.deleteIfExists(path)) removed = true; + } catch (IOException ignored) { + // Another sweeper removed it, or it is still open. + } + } + if (!keepInvestigations) { + try { + if (Files.deleteIfExists(target)) removed = true; + } catch (IOException ignored) { + // The directory can already be gone. + } + } + return removed || !Files.exists(target); + } + + /** Chrome SingletonLock and the Edge Chromium lockfile both mean the profile is open. */ + private static boolean isProfileLock(Path path) { + Path name = path.getFileName(); + if (name == null) return false; + String file = name.toString(); + return "SingletonLock".equals(file) || "lockfile".equals(file); + } + private static RunRecord readRecordQuiet(Path file) { if (!Files.isRegularFile(file)) return null; try { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java index e1f8814d4..20fa1cdd2 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchAgentCommands.java @@ -52,6 +52,8 @@ static int run( case "post" -> post(parsed, out, err); case "history" -> history(parsed, out, err); case "gc-runs" -> gcRuns(parsed, out, err); + case "gc-versions" -> gcVersions(parsed, out, err); + case "use-version" -> useVersion(parsed, out, err); default -> { err.println("Unknown Workbench agent command: " + parsed.command()); yield 2; @@ -406,6 +408,24 @@ private static int gcRuns(WorkbenchCommandLine.Parsed parsed, PrintStream out, P return 0; } + private static int gcVersions(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + AgentCoordination.VersionSweep sweep = AgentCoordination.sweepVersions(parsed.project(), java.time.Instant.now()); + out.println("versions-removed=" + sweep.versionsRemoved()); + return 0; + } + + private static int useVersion(WorkbenchCommandLine.Parsed parsed, PrintStream out, PrintStream err) { + if (parsed.version() == null || parsed.version().isBlank()) { + err.println("Usage: use-version --version="); + return 2; + } + try { + return PickleballLocalStore.useVersion(parsed.project(), parsed.version(), out, err); + } catch (java.io.IOException failure) { + throw new IllegalStateException(failure.getMessage(), failure); + } + } + private static java.time.Duration parseTtl(String raw) { String value = raw.trim(); if (value.startsWith("P") || value.startsWith("p")) { diff --git a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java index 8e83f52c5..7433ca7f4 100644 --- a/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java +++ b/src/main/java/tools/dscode/launcher/WorkbenchCommandLine.java @@ -17,7 +17,7 @@ final class WorkbenchCommandLine { static final Set AGENT_CORE_COMMANDS = Set.of( "export-guidance", "hint", "discover-hint", "discover", "confirm", "resolve-runvars", "short-log", "note", "inbox", "finish", - "presence", "post", "history", "gc-runs" + "presence", "post", "history", "gc-runs", "gc-versions", "use-version" ); static final Set SESSION_CLIENT_COMMANDS = Set.of( "isolate", "session-start", "execute-step", "status", "events", "stop", "kill", @@ -69,7 +69,8 @@ record Parsed( String example, String retention, String[] forwarded, - Coordination coordination + Coordination coordination, + String version ) { } @@ -88,14 +89,14 @@ static boolean isSessionClientCommand(String command) { static Parsed parse(String[] args) { Path cwd = Path.of("").toAbsolutePath().normalize(); if (args == null || args.length == 0) { - return new Parsed("ui", cwd, null, null, null, null, null, new String[]{"ui", cwd.toString()}, Coordination.empty()); + return new Parsed("ui", cwd, null, null, null, null, null, new String[]{"ui", cwd.toString()}, Coordination.empty(), null); } String command = args[0]; if ("export-guidance".equals(command)) { Path output = args.length >= 2 && !isFlag(args[1]) ? Path.of(args[1]) : Path.of(".pickleball"); - return new Parsed(command, cwd, output, null, null, null, null, args.clone(), Coordination.empty()); + return new Parsed(command, cwd, output, null, null, null, null, args.clone(), Coordination.empty(), null); } String tags = null; @@ -122,6 +123,7 @@ static Parsed parse(String[] args) { Integer limit = null; String id = null; String ttl = null; + String version = null; String absorb = null; Path project = null; List rest = new ArrayList<>(); @@ -320,6 +322,16 @@ static Parsed parse(String[] args) { absorb = null; continue; } + if (token.startsWith("--version=")) { + version = blankToNull(token.substring("--version=".length())); + absorb = null; + continue; + } + if ("--version".equals(token) && index + 1 < args.length) { + version = blankToNull(args[++index]); + absorb = null; + continue; + } if (token.startsWith("--ttl=")) { ttl = blankToNull(token.substring("--ttl=".length())); absorb = null; @@ -380,10 +392,10 @@ static Parsed parse(String[] args) { forwarded.addAll(rest); return new Parsed( command, project, null, tags, name, example, retention, forwarded.toArray(String[]::new), - coordination + coordination, version ); } - return new Parsed(command, project, null, tags, name, example, retention, args.clone(), coordination); + return new Parsed(command, project, null, tags, name, example, retention, args.clone(), coordination, version); } private static Integer parseWholeNumber(String value, String label) { diff --git a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md index a9e59ff37..63ef4a5f0 100644 --- a/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md +++ b/src/main/resources/META-INF/pickleball/guidance/AGENT-GUIDE.md @@ -20,7 +20,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `gc-versions`, `use-version`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. @@ -39,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Chrome `SingletonLock` and an Edge `lockfile` both mean the profile is open. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. The last failed run is the latest stopped run whose status is FAILED, or a STOPPED record with a failure count, a failed-scenario flag, or a non-empty failure summary. `finish` writes FAILED when that run had failures and STOPPED only when it did not. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. One window has one driver. Each agent uses its own run id. Launcher commands are one-shot and exit. A 10-minute presence sweep is optional and cannot be enforced. @@ -72,9 +72,13 @@ Project history. It must survive `export-guidance` and a version bump. It stays - `investigations/` - `presence/`, `posts/`, `history.log` -`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. New session, attach, last-discover, logs, and `live/classes` stay under `v//workbench` when `current.json` is complete. Do not describe both layouts as current. +`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. Session, attach, and last-discover stay under `v//workbench` for the version that owns them. When `current.json` is complete, the next launch uses `v//workbench`. Switching the pointer does not destroy the other version's workbench tree. Do not describe both layouts as current. -Export cleanup is a manifest diff of generated files in that version folder. There is no general `.pickleball` garbage collector. Do not delete other `v//` trees until the new `current.json` is complete and a launch has succeeded. Keeping the previous version tree is enough. Never automatically delete `current.json`, `open/`, or the `v//` tree named by a complete pointer, or an in-progress export (`complete: false`). +Export cleanup is a manifest diff of generated files in that version folder. `export-guidance` and `use-version` do not delete any other `v//` tree. `gc-versions` is the explicit version sweep. It is not a daemon, and it is not run from `export-guidance`, `use-version`, or finish. It may delete `v//` only when that version is not named by a usable `current.json`, its last use is at least 3 days ago, no RUNNING run was started with it, and that version's workbench tree has no live session, attach, or worker. A missing or unreadable `v//.last-used` counts as last used now, so an old tree is not deleted on the first sweep. Deleting the tree removes that version's guidance, controller, libs, and workbench state only. It does not delete `runs/`, `investigations/` (including a legacy `v//investigations` tree), `presence/`, `posts/`, `inbox/`, `agent-log`, or `history.log`. Two sweepers deleting the same expired tree must be safe. Never delete `current.json`, `open/`, or the `v//` tree named by a usable pointer. + +A version tree is last used when `export-guidance` completes it or `use-version` points `current.json` at it. That time is `v//.last-used`. A switch in the same session updates `.last-used`, so the tree cannot expire while it is being tested. An unused version tree is expired, not stale. + +`use-version --version=` switches the pointer to an already complete `v//` export. It rewrites `current.json`, refreshes root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json`, and refreshes `open/` from that tree. It does not re-copy a sibling version and does not delete the tree being left. If that version tree is missing or not a complete export, it prints that `export-guidance` from that dependency is required and leaves `current.json` unchanged. The last `use-version` or `export-guidance` wins the pointer. Switching the pointer does not stop a run, delete a run, delete a browser profile, or clear presence, posts, inbox, investigations, or `history.log`. A run records the version it started with and is not retargeted when the pointer moves. An agent switches versions in one session with `use-version`, or with `export-guidance` from the dependency it wants. New investigation handoffs always go to `.pickleball/investigations//`. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read the legacy versioned path. `export-guidance` leaves both trees alone. @@ -122,12 +126,13 @@ A successful `export-guidance .pickleball` run: - copies root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` as aliases of that version so the well-known agent pointer stays stable; - writes relocatable Workbench openers under `.pickleball/open/`; - writes `.pickleball/current.json` last (`pickleballVersion`, `complete`, `updatedAt`) — a version pointer, not a path to the dependency jar; +- records last use on `v//.last-used`; - removes files managed by the previous manifest that are no longer shipped in that version folder, while leaving unrelated files alone, including `.pickleball/investigations/` and any other `v//` trees; and - best-effort ensures `.pickleball` is ignored by Git, preferring an existing `.gitignore` and then repository-local `.git/info/exclude`. Any Pickleball execution (`PickleballRunner`, `java -jar pickleball.jar`, Workbench launcher) lazily materializes the running jar's version folder if it is missing. It unpacks from the running jar; it never copies a sibling version folder. Direct `java -jar pickleball-X.jar` pins `current.json` to X. Open scripts set `PKB_OPEN_BOOTSTRAP=1` so Java hops to the pinned jar when `current.json` is complete. -The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. The manifest records the last completed export; it is not a substitute for rerunning the exporter. +The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. It does not mean an old version tree. An unused version tree is expired, not stale. An agent switches versions in one session with `use-version --version=`, or with `export-guidance` from the dependency it wants. The manifest records the last completed export; it is not a substitute for rerunning the exporter. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Version folders keep each export isolated. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. 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 407ae37c7..83014ac2b 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 @@ -8,7 +8,7 @@ This file maps consumer-visible capabilities to implementation anchors, executab | Neutral controller/worker wire protocol | `pickleball-control-protocol/src/main/java/tools/dscode/control/protocol`; `ControlProtocol`; `ControlBridgeRequests`; `ControlBridgeResponses`; immutable `ControlBridge*` records; `InvestigationHandoff`; `PickleballLocalLayout`; `PickleballVersion`; `PickleballArtifactLocator`; `PickleballLocalStore` | `verifyProtocolIsolation`; `ControlBridgeClientTest`; `InvestigationHandoffTest`; `PickleballLocalLayoutTest`; consumer `@control-bridge`; protocol/version/capability assertions | `docs/pickleball-workbench.md`; `docs/diagnostic-reporting.md`; `pickleball-workbench/AGENTS.md` | | Pickleball Workbench synchronization / persistent live worker / MCP stdio / player-style Swing+WebView UI / watched-agent control lease / scenario name-tag filter / Gherkin text editor / file-backed Gherkin / step resolve / explorer peek vs Play / `workbench_go` / Report tab / live isolate Explorer tail / sealed RunVar panel | `pickleball-workbench`; `WorkbenchApplication`; `WorkbenchRuntimeBoundary`; `WorkbenchServices`; `WorkbenchController`; `tools.dscode.workbench.lease`; `WorkbenchAttachServer`; `WorkbenchCommandQueue`; `WorkbenchSessionActions`; `cli-session.json`; `tools.dscode.workbench.sync`; `WorkbenchSyncPlanner`; `WorkbenchSyncInputs`; `WorkbenchWorkerManager`; `WorkbenchLiveSession`; `tools.dscode.workbench.bridge.ControlBridgeClient`; `tools.dscode.workbench.mcp`; `workbench_step_resolve`; `workbench_diagnostic_catalog`; `workbench_diagnostic_run`; `workbench_diagnostic_summary`; `workbench_investigation_emit`; `workbench_go`; `tools.dscode.workbench.player`; `GherkinPlayPlan`; `GherkinReference`; `GherkinTextEditing`; `LiveEditorView`; `LiveBufferSidecar`; `tools.dscode.workbench.catalog`; `ScenarioFilter`; `ConsumerFeatureCatalog`; `JavaGlueIndex`; `CatalogTargetResolver`; `StepMapSeeds`; `DiagnosticEvidenceNavigator.ReplayBeat`; `tools.dscode.workbench.mapping`; `tools.dscode.workbench.terminal`; `tools.dscode.workbench.diagnostics`; `tools.dscode.workbench.ui`; `RunVarOverridePanel`; `ConfigTabModel`; `LongWork`; `WorkbenchUiSettings`; `FeaturePickerPanel`; OpenJFX `WebView` / `JFXPanel`; protocol `ControlProtocol.WORKER_MAIN_CLASS`; `PickleballVersion.running` in the player bar | `:pickleball-workbench:test`; `WorkbenchRuntimeBoundaryTest`; `ConsumerFeatureCatalogTest`; `ScenarioFilterTest`; `GherkinPlayPlanTest`; `GherkinReferenceTest`; `GherkinBlockDocumentTest`; `GherkinTextEditingTest`; `LivePlaybackCoordinatorTest`; `LiveFeatureSaveTest`; `LiveBufferSidecarTest`; `LiveEditorViewTest`; `WorkbenchControlLeaseTest`; `WorkbenchControllerLeaseTest`; `WorkbenchAttachServerTest`; `WorkbenchCommandQueueTest`; `MappingValueCodecTest`; `WorkerLogBufferTest`; `DiagnosticEvidenceNavigatorTest`; `InvestigationHandoffTest`; `WorkbenchSynchronizerTest`; `WorkbenchSyncPlannerTest`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest`; `WorkbenchUiSettingsTest`; `RunVarOverridePanelTest`; `ConfigTabModelTest`; `LongWorkTest`; `LastDiscoverSnapshotTest`; `LiveScenarioPlayerTest`; packaged UI/MCP probes | `docs/pickleball-workbench.md`; `docs/pickleball-workbench-player.md`; `docs/consumer-agent-guide.md`; `pickleball-workbench/AGENTS.md` | | Consumer-side Control Bridge and live investigation | worker-side `pickleball-control-api/src/main/java/tools/dscode/control/bridge`; core `ControlRuntime`; protocol `tools.dscode.control.protocol`; controller client `tools.dscode.workbench.bridge.ControlBridgeClient`; endpoints `/v1/status`, `/v1/scenarios`, `/v1/events`, `/v1/pause`, `/v1/resume`, `/v1/steps/execute`, `/v1/steps/resolve`, `/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`; protocol `ControlBridgeStepResolutionTest`; worker `ControlBridgeStepResolverTest` | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | -| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `.pickleball/investigations/`; `.pickleball/presence/`; `.pickleball/posts/`; `.pickleball/history.log`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | +| Consumer Workbench launcher/extractor | `tools.dscode.launcher.PickleballWorkbenchLauncher` (outer JAR `Main-Class`); `PickleballLocalLayout`; `PickleballLocalStore`; `PickleballArtifactLocator`; `PickleballVersion`; `WorkbenchAgentCommands`; `WorkbenchSessionCommands`; `WorkbenchCommandLine`; `AgentCoordination`; `RunConfigCopy`; `.pickleball/agent-log`; `.pickleball/runs//`; `.pickleball/inbox/`; `.pickleball/investigations/`; `.pickleball/presence/`; `.pickleball/posts/`; `.pickleball/history.log`; `AgentDiscoverPlanner`; `ControlProtocol.EMBEDDED_WORKBENCH_RESOURCE`; root `shadowJar`; `verifyEmbeddedWorkbench`; content-addressed `.pickleball/v//workbench/controller//` extraction (legacy `.pickleball/workbench/` without `current.json`); relocatable `.pickleball/open/` scripts; agent verbs `export-guidance`, `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `gc-versions`, `use-version`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`; the Swing window calls those session commands and does not keep a second implementation; when a window is already open the agent drives that session, and when nobody is watching `isolate` stays headless; public CLI `--tags` / `--name` / `--example` / `--retention=all\|failed\|none` on discover/confirm/hint/isolate (Discover default `failed`); `pkb_example` is Examples-row selection after tags and name, not a tag; `example` is that same filter for the open session; project builds use `mvnw`/`mvnw.cmd`, `gradlew`/`gradlew.bat`, or `java -jar` on the wrapper jar and do not require a machine-wide Maven or Gradle install | `PickleballWorkbenchLauncherTest`; `PickleballLocalLayoutTest`; `PickleballLocalStoreTest`; `PickleballArtifactLocatorTest`; `PickleballVersionTest`; `WorkbenchAgentCommandsTest`; `WorkbenchCommandLineTest`; `WorkbenchSessionCommandsTest`; `AgentCoordinationTest`; `RunConfigIsolationTest`; `AgentDiscoverPlannerTest`; `WorkbenchPointerContractTest`; `verifyEmbeddedWorkbench`; `verifyWorkbenchEntrypoint` | `docs/getting-started.md`; `docs/consumer-project.md`; `docs/pickleball-workbench.md`; `docs/consumer-agent-guide.md`; `docs/diagnostic-reporting.md` | | Step Override runtime and Workbench authoring | `src/main/java/tools/dscode/control/override`; `src/main/java/io/cucumber/core/runner/StepOverrideDispatcher.java`; `NPickleStepTestStepFactory`; `src/main/aspectj/tools/dscode/control/override/StepOverrideLifecycleAspect.aj`; bridge `/v1/step-overrides*`; `ControlBridgeClient`; `WorkbenchLiveSession`; MCP `workbench_step_override_*` | `StepOverrideCompilerTest`; `StepOverrideChecks`; `StepOverrideBridgeTestSteps`; `@step-override`; Workbench `live-check`; `WorkbenchMcpServerTest`; `WorkbenchUiControllerTest` | `docs/step-overrides.md`; `docs/pickleball-workbench.md`; `pickleball-workbench/AGENTS.md` | | Dynamic control API and semantic hooks | `pickleball-control-api/src/main/java/tools/dscode/control/api`; `src/main/java/tools/dscode/common/control`; `src/main/aspectj/tools/dscode/common/control/ControlRuntimeAspect.aj`; search `DynamicControl`, `MappingControl`, `ElementControl`, `ServiceCallControl`, `ControlHook` | `DynamicControlApiChecks.java`; `ControlRuntimeObserverChecks.java`; `internal-framework-java-checks.feature`; `control-bridge.feature` | `docs/dynamic-control-api.md` | | Pickleball-native element inspection | `ElementControl.java`; `ElementInspection.java`; `ElementEvidence.java`; `ExecutionDictionary.java`; `DefinitionContext.java`; `BrowserSteps.getCurrentDriverIfPresent`; bridge `/v1/browser/elements`; Workbench `workbench_element_inspect` | `@control-bridge`; custom element/category consumer scenarios | `docs/dynamic-control-api.md`; `docs/pickleball-workbench.md`; `docs/custom-element-definitions.md` | 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 85ca72f28..7c9d9bf64 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 @@ -424,6 +424,7 @@ This inventory helps coding agents discover relevant files. It does not replace - `src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java` - `src/test/java/tools/dscode/control/protocol/PickleballVersionTest.java` - `src/test/java/tools/dscode/launcher/PickleballWorkbenchLauncherTest.java` +- `src/test/java/tools/dscode/launcher/VersionSwitchTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchAgentCommandsTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java` - `src/test/java/tools/dscode/launcher/WorkbenchPointerContractTest.java` @@ -822,10 +823,13 @@ This inventory helps coding agents discover relevant files. It does not replace ## .pickleball owners Jar cache is regenerable under `v//`, `open/`, and `current.json`. +Side-by-side `v//` trees stay until an explicit `gc-versions`. +`use-version` points `current.json` at an already exported tree and does not delete the others. Project history stays at the `.pickleball` root and survives export-guidance. Legacy `.pickleball/workbench/` is only the fallback when `current.json` is missing or not usable. - `.pickleball/v//` +- `.pickleball/v//.last-used` - `.pickleball/open/` - `.pickleball/current.json` - `.pickleball/runs//` @@ -848,3 +852,5 @@ Launcher verbs, same exec.args style as inbox, note, and short-log: - `history --append` - `history --tail` - `gc-runs` +- `gc-versions` +- `use-version --version=` diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md index 6b2151356..aa9fdd2bc 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/configuration.md @@ -126,13 +126,13 @@ The short log is `.pickleball/agent-log`. Read it before a run, after a run, and `inbox//` is directed mail for that agent. `take` deletes that note. `list` does not. `inbox/any` is not first-taker-wins. A note to every agent is a post, not a consumed inbox file. The inbox is not a second log and not an assignment framework. -The board is three files, not more inbox folders. `.pickleball/presence/.json` is a heartbeat (`agent id`, `lastSeen`, current run ids). An active agent may drop a row older than 15 minutes. That drop removes the row only. `.pickleball/posts/.json` is a hint (`id`, `from`, `to` an agent id or `all`, optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, optional `ackedBy`). Default expiry is 24 hours from the last update. One hour is appropriate for "I have the window" and is not the default. List does not delete. Ack is optional. `.pickleball/history.log` is an append-only summation (version, files, fix, branch, commit, merge, run id), trimmed from the front above 10 MB. It is not a mailbox. Agents are not daemons. Launcher commands are one-shot: `presence --touch|--list|--sweep`, `post --write|--list|--renew|--sweep`, `history --append|--tail`, and `gc-runs`. +The board is three files, not more inbox folders. `.pickleball/presence/.json` is a heartbeat (`agent id`, `lastSeen`, current run ids). An active agent may drop a row older than 15 minutes. That drop removes the row only. `.pickleball/posts/.json` is a hint (`id`, `from`, `to` an agent id or `all`, optional run id, one line, `createdAt`, `updatedAt`, `expiresAt`, optional `ackedBy`). Default expiry is 24 hours from the last update. One hour is appropriate for "I have the window" and is not the default. List does not delete. Ack is optional. `.pickleball/history.log` is an append-only summation (version, files, fix, branch, commit, merge, run id), trimmed from the front above 10 MB. It is not a mailbox. Agents are not daemons. Launcher commands are one-shot: `presence --touch|--list|--sweep`, `post --write|--list|--renew|--sweep`, `history --append|--tail`, `gc-runs`, `gc-versions`, and `use-version --version=`. -`browser-profile/` under a run is deleted only by `gc-runs`, and only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Remote drivers and a consumer-supplied `user-data-dir` are not this directory. Dense evidence stays for the run open in Workbench and for the last failed run. Other stopped payloads may go 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile`. Never delete a run with no stop line. `finish` and `stop` only write `stoppedAt` and a stop line. +`browser-profile/` under a run is deleted only by `gc-runs`, and only when that run has a stop line, nothing has the profile open, and no agent holds the run. Chrome `SingletonLock` and an Edge `lockfile` both mean the profile is open. Not when Cucumber returns, and not on finish alone. Remote drivers and a consumer-supplied `user-data-dir` are not this directory. Dense evidence stays for the run open in Workbench and for the last failed run. The last failed run is the latest stopped run whose status is FAILED, or a STOPPED record with a failure count, a failed-scenario flag, or a non-empty failure summary. `finish` writes FAILED when that run had failures and STOPPED only when it did not. Other stopped payloads may go 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile`. Never delete a run with no stop line. `finish` and `stop` only write `stoppedAt` and a stop line. `current.json` is a version pointer (`pickleballVersion`, `complete`, `updatedAt`), not a path to the dependency jar. `export-guidance` of version X writes `v/X`, records `v/X/.last-used`, and points `current.json` at X. It leaves every other `v//` tree on disk. `use-version --version=` does the same pointer move when that tree is already a complete export, and refreshes the root aliases and `open/` from that tree. It does not invent a missing export and does not delete the tree being left. Session, attach, and last-discover stay under `v//workbench` for the version that owns them. A run records the version it started with. Moving the pointer does not retarget a RUNNING run. `gc-versions` is explicit. It is not run from export, use-version, or finish. It deletes a version tree only when it is not the usable current pointer, last use is at least 3 days ago, no RUNNING run started with it, and its workbench has no live session, attach, or worker. A missing `.last-used` counts as last used now. An unused version tree is expired, not stale. Stale still means an untrusted export. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Two headless isolates each get their own session file under the run directory. Commands without `--run-id` still target the open window, or the legacy shared CLI session when no window is open. Commands with `--run-id` look only at that run's session file. The window is a view of `.pickleball/runs/`. Its run dropdown and `show-run --run-id` read that same directory. `open-window --run-id` opens the one window on a run the agent already has and does not start a test. `close-window` closes the window and does not delete the run directory, the profile, the session file, or `.pickleball/agent-log`. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. -Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, `presence`, `post`, `history`, `gc-runs`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. They do not delete the run. +Same launcher as the other session commands, project wrapper only: `short-log`, `note --text=`, `inbox --write|--list|--take`, `presence`, `post`, `history`, `gc-runs`, `gc-versions`, `use-version --version=`, and `finish --run-id= [--learned=]`. `discover`, `confirm`, and `isolate` write the start line themselves. `finish` and `stop --run-id=` write the stop line and the learned field when the caller supplies it. They do not delete the run. ## Named profiles diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md index a9e59ff37..63ef4a5f0 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-agent-guide.md @@ -20,7 +20,7 @@ mvnw -q org.codehaus.mojo:exec-maven-plugin:3.5.0:java "-Dexec.mainClass=tools.d Windows is the same command with `mvnw.cmd`. A Gradle consumer that exposes the launcher task uses `gradlew` or `gradlew.bat`. -Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. +Same launcher for `hint`, `discover`, `confirm`, `resolve-runvars`, `short-log`, `note`, `inbox`, `finish`, `presence`, `post`, `history`, `gc-runs`, `gc-versions`, `use-version`, `isolate`, `execute-step`, `status`, `events`, `stop`, `open-scenario`, `example`, `play`, `from-here`, `pause`, `insert-step`, `update-step`, `diagnostic-run`, `save`, `refresh`, `session-sync`, `worker-start`, `worker-restart`, `worker-stop`, `open-window`, `close-window`, and `show-run` — change `exec.args` only. The Workbench window is a view of a run you already have. `open-window --run-id=` opens the one window on that run. `show-run --run-id=` loads that run's record, logs, reports, and config. The window's run dropdown reads the same `.pickleball/runs/` directory. Neither command starts a test. `close-window` closes the window and leaves the run directory, a headless run, and the short log in place. Play, step, and stop apply only to the one live run. Another agent may view a different run read-only and does not become a second driver of the live session. There is no Gherkin step that opens the window. An agent that can only run a scenario and cannot call the launcher cannot open it. @@ -39,7 +39,7 @@ Do not copy consumer features into `.pickleball` as a sandbox. Read the short log `.pickleball/agent-log` before a run, after a run, and during a long session, instead of the dense diagnostic log. Use your own run id. When a Workbench window is already open, only one agent drives it. Other agents stay headless on their own run ids. Agents are not daemons. Write start and stop lines. Put purpose and findings on the run record. Use the inbox only for a short note to one named agent. Taking that note deletes it. A note for every agent is a post (`post --write --to=all`), not an inbox file, and listing or taking does not delete it. -`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. +`discover`, `confirm`, and `isolate` accept optional `--run-id`, `--agent` (or `--agent-id`), `--group`, `--sequence`, `--who`, and `--why`. They write the start line and `.pickleball/runs//record.json`. The isolation unit is the run id. One agent may own many run ids. Uncoordinated agents each get their own id. Two runs never share a folder, a config copy, a session file, or a browser profile. A private runs directory is only for an agent run or a Workbench run. A normal `mvn test` does not create `.pickleball/runs`, does not set `user-data-dir`, and keeps `reports/cucumber-report.html`. Reports, diagnostic packs, scratch, and a headless session file for an agent or Workbench run stay under that run directory. Each parallel local Chrome or Edge worker gets its own `browser-profile/` directory. Remote drivers and a consumer-supplied `user-data-dir` are not that directory and are left alone. `finish` and `stop` only write `stoppedAt` and a stop line. They do not delete the run directory, the profile, the session file, or the log. `close-window` keeps that behavior. `gc-runs` is the explicit payload sweep. It is not run from finish. Delete `browser-profile/` only when that run has a stop line, nothing has the profile open, and no agent holds the run. Chrome `SingletonLock` and an Edge `lockfile` both mean the profile is open. Not when Cucumber returns, and not on finish alone. Keep dense evidence for the run open in Workbench and for the last failed run. The last failed run is the latest stopped run whose status is FAILED, or a STOPPED record with a failure count, a failed-scenario flag, or a non-empty failure summary. `finish` writes FAILED when that run had failures and STOPPED only when it did not. Other stopped run payloads may be removed 24 hours after `stoppedAt`. Always leave `record.json`, the sparse index, and `pkb_run_profile` (the resolved RunVar snapshot, not the Chrome directory). Never delete a run with no stop line, a `RUNNING` run, another agent's held run, a live session, a live worker, or the run the open window is showing. Two sweepers removing the same expired payload must be safe. A private run copies the project configs into `.pickleball/runs//config` once and reads and writes only that copy. Change a run's configs there, or in the Workbench Config tab beside Mapping. Do not edit the project config files for one run. Saving in the tab writes the run copy. A normal `mvn test` does not copy configs and still reads the project files. Discover and Workbench, including a person collaborating with an agent, default `pkb_compositeReport=false` and `pkb_scenarioReport=false`. A normal test still writes both HTML reports. An explicit `true` writes that report again. Agents keep diagnostic logs. `finish --run-id= --learned=` and `stop --run-id=` write the stop line. `short-log` prints recent lines. `note --text=` appends a note. `inbox --write --to= --text=` leaves directed mail; `inbox --list` does not delete it; `inbox --take --agent=` deletes that note. `inbox/any` is not a broadcast. Use `post` for that. Same launcher; only change `-Dexec.args`. Project wrapper only (`mvnw` or `mvnw.cmd`, `gradlew` or `gradlew.bat`, or `java -jar` on the wrapper jar). Do not require a machine-wide Maven or Gradle install. One window has one driver. Each agent uses its own run id. Launcher commands are one-shot and exit. A 10-minute presence sweep is optional and cannot be enforced. @@ -72,9 +72,13 @@ Project history. It must survive `export-guidance` and a version bump. It stays - `investigations/` - `presence/`, `posts/`, `history.log` -`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. New session, attach, last-discover, logs, and `live/classes` stay under `v//workbench` when `current.json` is complete. Do not describe both layouts as current. +`.pickleball/workbench/` is the legacy fallback when `current.json` is missing or not usable. Session, attach, and last-discover stay under `v//workbench` for the version that owns them. When `current.json` is complete, the next launch uses `v//workbench`. Switching the pointer does not destroy the other version's workbench tree. Do not describe both layouts as current. -Export cleanup is a manifest diff of generated files in that version folder. There is no general `.pickleball` garbage collector. Do not delete other `v//` trees until the new `current.json` is complete and a launch has succeeded. Keeping the previous version tree is enough. Never automatically delete `current.json`, `open/`, or the `v//` tree named by a complete pointer, or an in-progress export (`complete: false`). +Export cleanup is a manifest diff of generated files in that version folder. `export-guidance` and `use-version` do not delete any other `v//` tree. `gc-versions` is the explicit version sweep. It is not a daemon, and it is not run from `export-guidance`, `use-version`, or finish. It may delete `v//` only when that version is not named by a usable `current.json`, its last use is at least 3 days ago, no RUNNING run was started with it, and that version's workbench tree has no live session, attach, or worker. A missing or unreadable `v//.last-used` counts as last used now, so an old tree is not deleted on the first sweep. Deleting the tree removes that version's guidance, controller, libs, and workbench state only. It does not delete `runs/`, `investigations/` (including a legacy `v//investigations` tree), `presence/`, `posts/`, `inbox/`, `agent-log`, or `history.log`. Two sweepers deleting the same expired tree must be safe. Never delete `current.json`, `open/`, or the `v//` tree named by a usable pointer. + +A version tree is last used when `export-guidance` completes it or `use-version` points `current.json` at it. That time is `v//.last-used`. A switch in the same session updates `.last-used`, so the tree cannot expire while it is being tested. An unused version tree is expired, not stale. + +`use-version --version=` switches the pointer to an already complete `v//` export. It rewrites `current.json`, refreshes root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json`, and refreshes `open/` from that tree. It does not re-copy a sibling version and does not delete the tree being left. If that version tree is missing or not a complete export, it prints that `export-guidance` from that dependency is required and leaves `current.json` unchanged. The last `use-version` or `export-guidance` wins the pointer. Switching the pointer does not stop a run, delete a run, delete a browser profile, or clear presence, posts, inbox, investigations, or `history.log`. A run records the version it started with and is not retargeted when the pointer moves. An agent switches versions in one session with `use-version`, or with `export-guidance` from the dependency it wants. New investigation handoffs always go to `.pickleball/investigations//`. Do not delete an existing `v//investigations` tree. If the root path for an id is absent, read the legacy versioned path. `export-guidance` leaves both trees alone. @@ -122,12 +126,13 @@ A successful `export-guidance .pickleball` run: - copies root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` as aliases of that version so the well-known agent pointer stays stable; - writes relocatable Workbench openers under `.pickleball/open/`; - writes `.pickleball/current.json` last (`pickleballVersion`, `complete`, `updatedAt`) — a version pointer, not a path to the dependency jar; +- records last use on `v//.last-used`; - removes files managed by the previous manifest that are no longer shipped in that version folder, while leaving unrelated files alone, including `.pickleball/investigations/` and any other `v//` trees; and - best-effort ensures `.pickleball` is ignored by Git, preferring an existing `.gitignore` and then repository-local `.git/info/exclude`. Any Pickleball execution (`PickleballRunner`, `java -jar pickleball.jar`, Workbench launcher) lazily materializes the running jar's version folder if it is missing. It unpacks from the running jar; it never copies a sibling version folder. Direct `java -jar pickleball-X.jar` pins `current.json` to X. Open scripts set `PKB_OPEN_BOOTSTRAP=1` so Java hops to the pinned jar when `current.json` is complete. -The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. The manifest records the last completed export; it is not a substitute for rerunning the exporter. +The exporter does not create/commit a new `.gitignore`, alter the Git index, or untrack files that were already committed. If export fails, treat generated guidance as potentially stale. Stale means an untrusted export: `complete: false`, a missing `current.json`, or a failed export. It does not mean an old run. It does not mean an old version tree. An unused version tree is expired, not stale. An agent switches versions in one session with `use-version --version=`, or with `export-guidance` from the dependency it wants. The manifest records the last completed export; it is not a substitute for rerunning the exporter. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files behind after a downgrade. Version folders keep each export isolated. Prefer the dependency actually resolved on the test classpath and files freshly exported by that dependency. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md index 10ad41712..353236ba4 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/consumer-project.md @@ -26,7 +26,7 @@ Then read or browse: .pickleball/v//maven-consumer-project/ ``` -Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current version so the well-known agent pointer stays stable. Canonical files live under `v//`. Rerun export before Pickleball work even when `.pickleball` already exists. A successful export overwrites current managed files, removes obsolete previously managed files from that version folder, writes `current.json` last with `complete: true`, and best-effort keeps `.pickleball` ignored by Git. If export fails, treat existing generated guidance as potentially stale. +Root `AGENT-GUIDE.md` and `GUIDANCE-MANIFEST.json` are aliases of the current version so the well-known agent pointer stays stable. Canonical files live under `v//`. Rerun export before Pickleball work even when `.pickleball` already exists. A successful export overwrites current managed files, removes obsolete previously managed files from that version folder, writes `v//.last-used`, writes `current.json` last with `complete: true`, and best-effort keeps `.pickleball` ignored by Git. `export-guidance` and `use-version` leave every other version tree in place. `use-version --version=` points `current.json` at an already complete export and refreshes the root aliases and `open/` from that tree. A missing export is not invented; run `export-guidance` from that dependency instead. `gc-versions` is the explicit sweep for a tree last used at least 3 days ago. It is not part of export or of switching. If export fails, treat existing generated guidance as potentially stale. Stale means an untrusted export, not an old version tree. Compatibility note: an older Pickleball release whose exporter predates the manifest lifecycle may leave newer files or a newer manifest behind after a downgrade. Version folders keep each export isolated, so a downgrade reuses `v//` and rewrites `current.json` rather than mixing docs. Prefer the dependency actually resolved on the test classpath and the files freshly exported by that dependency. `current.json` is a version pointer, not a path to the Maven jar. diff --git a/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md b/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md index b0926184c..97425fc17 100644 --- a/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md +++ b/src/main/resources/META-INF/pickleball/guidance/docs/diagnostic-reporting.md @@ -183,7 +183,7 @@ links[] # optional workbench_go / wb:// targets Do not parse Gherkin in the Workbench controller to invent `executionMap`. If the agent omitted it, derive it from `events.jsonl` at emit. v1 JSON still renders. Workbench Report is the same JSON. -`export-guidance` does not manage or delete `.pickleball/investigations/` or any legacy `v//investigations/` tree. +`export-guidance` does not manage or delete `.pickleball/investigations/` or any legacy `v//investigations/` tree. `gc-versions` removes an expired version's guidance and workbench state only, and leaves that investigations tree in place. ## Outcomes and completion 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 b15f9af06..903478bf0 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 @@ -132,7 +132,7 @@ Default Discover/Confirm stay on `pkb_runvars`. `hint` and `resolve-runvars` pri Humans who want the Swing player can pass `ui .` instead. With no launcher arguments, `ui` and the current directory are selected automatically for that human default. Other Workbench commands are forwarded in the same form, for example `"-Dexec.args=sync ."`. Headless `mcp .` is optional host wiring, not an agent setup step. Do not open the GUI for your own testing. Open it to show a person a specific run, or when they ask. Close it when you are done showing it. While testing for yourself, stay headless. Opening the window loads the run you already have. It does not start a second test. Do not click the JavaFX or WebView UI. When a person already has the window open, the session commands above drive that session so the person sees the result. When nobody is watching, stay on headless `discover`, `confirm`, and `isolate`. See `.pickleball/AGENT-GUIDE.md`. -The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. +The published Pickleball JAR is also executable. `java -jar pickleball-.jar ui ` is the outer `Main-Class` (`PickleballWorkbenchLauncher`): it still extracts the nested controller and forks `java -cp`. It does not merge Workbench onto the Pickleball classpath. `java -jar pickleball-.jar export-guidance .pickleball` is JDK-only: it copies bundled guidance through `PickleballLocalStore` and does not load DiagnosticCli or Jackson. Relocatable openers under `.pickleball/open/` walk up to the consumer project, find Java, and resolve a pickleball jar from `current.json`, a sibling `pickleball-*.jar`, or Maven local / the Gradle cache. They do not bake in a project, m2, or version path. Direct `java -jar pickleball-X.jar` uses X and pins `current.json` to X so versions can be tested side by side under `.pickleball/v//`. `use-version --version=` points `current.json` at an already exported tree without deleting the others. `gc-versions` is the explicit sweep for a tree unused for 3 days. It is not run from export or from switching. Gradle consumers can expose the same dependency-owned launcher without resolving a cache path or adding a Workbench dependency: diff --git a/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java index c094d9ef6..6fd0de972 100644 --- a/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java +++ b/src/test/java/tools/dscode/common/coordination/AgentCoordinationTest.java @@ -509,6 +509,60 @@ void expiredPayloadLeavesTheRecordSparseIndexAndRunProfile() throws Exception { assertTrue(Files.isDirectory(old.dataDirectory())); } + @Test + void stoppedRunWithAFailureCountIsTheLastFailedRun() throws Exception { + AgentCoordination.Run failed = AgentCoordination.begin(project, request("run-stopped-failed", "agent-gc")); + Files.writeString(failed.reportsDirectory().resolve("shot.png"), "png"); + AgentCoordination.finish(project, failed.runId(), null, "STOPPED", Instant.parse("2020-01-03T00:00:00Z"), null); + String record = Files.readString(failed.recordFile()) + .replace("\"failureCount\" : null", "\"failureCount\" : 2") + .replace("\"failureCount\": null", "\"failureCount\": 2"); + assertTrue(record.contains("STOPPED"), record); + Files.writeString(failed.recordFile(), record); + + AgentCoordination.Run clean = AgentCoordination.begin(project, request("run-clean-old", "agent-gc")); + Files.writeString(clean.reportsDirectory().resolve("shot.png"), "png"); + AgentCoordination.finish(project, clean.runId(), null, "STOPPED", Instant.parse("2020-01-04T00:00:00Z"), null); + + AgentCoordination.sweepRuns(project, Instant.parse("2026-10-06T00:00:00Z")); + assertTrue(Files.isRegularFile(failed.reportsDirectory().resolve("shot.png"))); + assertFalse(Files.exists(clean.reportsDirectory().resolve("shot.png"))); + assertTrue(Files.isRegularFile(clean.recordFile())); + assertTrue(Files.isRegularFile(clean.dataDirectory().resolve("pkb_run_profile")) + || Files.isRegularFile(clean.recordFile())); + } + + @Test + void finishWritesFailedWhenTheRunDirectoryShowsFailures() throws Exception { + AgentCoordination.Run failed = AgentCoordination.begin(project, request("run-evidence", "agent-finish")); + Files.createDirectories(failed.diagnosticDirectory()); + Files.writeString(failed.diagnosticDirectory().resolve("summary.json"), "{\"outcome\":\"FAILED\",\"counts\":{\"failed\":1}}"); + AgentCoordination.finish(project, failed.runId(), null, "STOPPED"); + String record = Files.readString(failed.recordFile()); + assertTrue(record.contains("\"status\" : \"FAILED\""), record); + + AgentCoordination.Run clean = AgentCoordination.begin(project, request("run-clean-finish", "agent-finish")); + AgentCoordination.finish(project, clean.runId(), null, "STOPPED"); + assertTrue(Files.readString(clean.recordFile()).contains("\"status\" : \"STOPPED\"")); + } + + @Test + void edgeLockfileHoldsTheProfileTheSameWayAsChromeSingletonLock() throws Exception { + AgentCoordination.Run edge = AgentCoordination.begin(project, request("run-edge", "agent-edge")); + Path cookies = edge.browserProfileDirectory().resolve("Default").resolve("Cookies"); + Files.createDirectories(cookies.getParent()); + Files.writeString(cookies, "edge"); + Files.writeString(edge.browserProfileDirectory().resolve("Default").resolve("lockfile"), "edge-lock"); + AgentCoordination.finish(project, edge.runId(), null, "STOPPED", Instant.now().minus(Duration.ofHours(2)), null); + AgentCoordination.sweepRuns(project, Instant.now()); + assertTrue(Files.isRegularFile(cookies)); + + Files.delete(edge.browserProfileDirectory().resolve("Default").resolve("lockfile")); + AgentCoordination.sweepRuns(project, Instant.now()); + assertFalse(Files.exists(cookies)); + assertTrue(Files.isRegularFile(edge.recordFile())); + } + private void captureProfile(List profiles, CountDownLatch ready, CountDownLatch go) { ready.countDown(); try { diff --git a/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java b/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java index 26d20feb2..641ee48b8 100644 --- a/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java +++ b/src/test/java/tools/dscode/control/protocol/PickleballLocalStoreTest.java @@ -51,6 +51,8 @@ void versionedExportWritesCurrentJsonOpenScriptsAndRootAliases() throws Exceptio assertFalse(script.contains("/home/")); assertTrue(Files.readString(pickleball.resolve("AGENT-GUIDE.md")).contains("v/" + version + "/")); assertTrue(out.toString(StandardCharsets.UTF_8).contains("NEXT: follow AGENT-GUIDE")); + assertTrue(Files.isRegularFile(pickleball.resolve("v").resolve(version).resolve(".last-used"))); + assertTrue(Files.isRegularFile(versionRoot.resolve("open/pickleball-workbench.sh"))); assertTrue(PickleballLocalStore.alreadyCurrent(pickleball, version)); } @@ -99,6 +101,7 @@ void exportGuidanceDoesNotDeleteEitherInvestigationsTree() throws Exception { Files.writeString(versioned.resolve("investigation.json"), "{\"id\":\"version\"}\n"); Files.writeString(versioned.resolve("report.html"), "

version

\n"); Files.writeString(other.resolve("investigation.json"), "{\"id\":\"other\"}\n"); + Files.writeString(other.getParent().getParent().resolve("AGENT-GUIDE.md"), "keep-other-version\n"); Files.writeString(versionRoot.resolve("GUIDANCE-MANIFEST.json"), """ { "files": [ @@ -115,5 +118,65 @@ void exportGuidanceDoesNotDeleteEitherInvestigationsTree() throws Exception { assertTrue(Files.isRegularFile(versioned.resolve("investigation.json"))); assertTrue(Files.isRegularFile(versioned.resolve("report.html"))); assertTrue(Files.isRegularFile(other.resolve("investigation.json"))); + assertEquals("keep-other-version\n", Files.readString(pickleball.resolve("v/9.9.9/AGENT-GUIDE.md"))); + assertTrue(Files.isRegularFile(versionRoot.resolve(".last-used"))); + } + + @Test + void useVersionPointsAtAnExistingExportAndLeavesTheOtherTree() throws Exception { + Path project = Files.createTempDirectory(tempDir, "consumer"); + Path pickleball = project.resolve(".pickleball"); + writeComplete(pickleball, "2.0.0", "guide-two"); + writeComplete(pickleball, "2.1.0", "guide-two-one"); + PickleballLocalLayout.writeCurrent( + pickleball, + PickleballLocalLayout.CurrentPointer.completeNow("2.0.0") + ); + String siblingBefore = Files.readString(pickleball.resolve("v/2.0.0/AGENT-GUIDE.md")); + + assertEquals(0, PickleballLocalStore.useVersion(project, "2.1.0", System.out, System.err)); + assertEquals("2.1.0", PickleballLocalLayout.readCurrent(pickleball).orElseThrow().pickleballVersion()); + assertEquals("guide-two-one", Files.readString(pickleball.resolve("AGENT-GUIDE.md")).trim()); + assertEquals(siblingBefore, Files.readString(pickleball.resolve("v/2.0.0/AGENT-GUIDE.md"))); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.1.0/.last-used"))); + assertTrue(Files.readString(pickleball.resolve("open/pickleball-workbench.sh")).contains("open-2.1.0")); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.0.0/workbench/controller/keep.jar"))); + } + + @Test + void useVersionDoesNotMoveThePointerWhenTheExportIsMissing() throws Exception { + Path project = Files.createTempDirectory(tempDir, "consumer"); + Path pickleball = project.resolve(".pickleball"); + writeComplete(pickleball, "2.0.0", "guide-two"); + PickleballLocalLayout.writeCurrent( + pickleball, + PickleballLocalLayout.CurrentPointer.completeNow("2.0.0") + ); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + int status = PickleballLocalStore.useVersion( + project, + "9.9.9", + System.out, + new PrintStream(err, true, StandardCharsets.UTF_8) + ); + assertEquals(1, status); + assertTrue(err.toString(StandardCharsets.UTF_8).contains("export-guidance from Pickleball 9.9.9 is required")); + assertEquals("2.0.0", PickleballLocalLayout.readCurrent(pickleball).orElseThrow().pickleballVersion()); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.0.0/AGENT-GUIDE.md"))); + } + + private static void writeComplete(Path pickleball, String version, String marker) throws Exception { + Path root = pickleball.resolve("v").resolve(version); + Files.createDirectories(root.resolve("open")); + Files.createDirectories(root.resolve("workbench/controller")); + Files.writeString(root.resolve("AGENT-GUIDE.md"), marker + "\n"); + Files.writeString(root.resolve("GUIDANCE-MANIFEST.json"), """ + { + "pickleballVersion": "%s", + "files": ["AGENT-GUIDE.md"] + } + """.formatted(version)); + Files.writeString(root.resolve("open/pickleball-workbench.sh"), "#!/bin/sh\n# open-" + version + "\n"); + Files.writeString(root.resolve("workbench/controller/keep.jar"), "jar-" + version); } } diff --git a/src/test/java/tools/dscode/launcher/VersionSwitchTest.java b/src/test/java/tools/dscode/launcher/VersionSwitchTest.java new file mode 100644 index 000000000..0386dc17a --- /dev/null +++ b/src/test/java/tools/dscode/launcher/VersionSwitchTest.java @@ -0,0 +1,171 @@ +package tools.dscode.launcher; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.dscode.common.coordination.AgentCoordination; +import tools.dscode.control.protocol.PickleballLocalLayout; +import tools.dscode.control.protocol.PickleballLocalStore; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; + +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 VersionSwitchTest { + @TempDir + Path tempDir; + + @Test + void useVersionRewritesThePointerAndLeavesRunsInvestigationsAndTheOtherTree() throws Exception { + Path pickleball = tempDir.resolve(".pickleball"); + writeComplete(pickleball, "2.0.0", "guide-two"); + writeComplete(pickleball, "2.1.0", "guide-two-one"); + PickleballLocalLayout.writeCurrent(pickleball, PickleballLocalLayout.CurrentPointer.completeNow("2.0.0")); + Files.createDirectories(pickleball.resolve("investigations/keep")); + Files.writeString(pickleball.resolve("investigations/keep/investigation.json"), "{\"id\":\"keep\"}\n"); + Files.writeString(pickleball.resolve("history.log"), "keep history\n"); + Files.createDirectories(pickleball.resolve("posts")); + Files.writeString(pickleball.resolve("posts/note.json"), "{\"text\":\"stay\"}\n"); + Files.createDirectories(pickleball.resolve("inbox/agent-a")); + Files.writeString(pickleball.resolve("inbox/agent-a/note.json"), "{\"message\":\"stay\"}\n"); + + AgentCoordination.Run running = AgentCoordination.begin( + tempDir, + AgentCoordination.Request.of("run-live", "agent-live", null, null, null, null, "discover") + ); + Files.writeString(running.browserProfileDirectory().resolve("Cookies"), "live"); + String before = Files.readString(running.recordFile()); + assertTrue(before.contains("\"pickleballVersion\" : \"2.0.0\""), before); + assertTrue(before.contains("RUNNING")); + + Output switched = run("use-version", "--version=2.1.0", tempDir.toString()); + assertEquals(0, switched.exitCode(), switched.stderr()); + assertEquals("2.1.0", PickleballLocalLayout.readCurrent(pickleball).orElseThrow().pickleballVersion()); + assertEquals("guide-two-one", Files.readString(pickleball.resolve("AGENT-GUIDE.md")).trim()); + assertEquals("guide-two\n", Files.readString(pickleball.resolve("v/2.0.0/AGENT-GUIDE.md"))); + assertTrue(Files.readString(pickleball.resolve("open/pickleball-workbench.sh")).contains("open-2.1.0")); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.1.0/.last-used"))); + assertEquals(before, Files.readString(running.recordFile())); + assertTrue(Files.isRegularFile(running.browserProfileDirectory().resolve("Cookies"))); + assertTrue(Files.isRegularFile(pickleball.resolve("investigations/keep/investigation.json"))); + assertTrue(Files.readString(pickleball.resolve("history.log")).contains("keep history")); + assertTrue(Files.isRegularFile(pickleball.resolve("posts/note.json"))); + assertTrue(Files.isRegularFile(pickleball.resolve("inbox/agent-a/note.json"))); + assertEquals(0, AgentCoordination.sweepRuns(tempDir, Instant.now()).profilesRemoved()); + assertTrue(Files.isRegularFile(running.browserProfileDirectory().resolve("Cookies"))); + } + + @Test + void useVersionToAMissingVersionDoesNotMoveThePointer() throws Exception { + Path pickleball = tempDir.resolve(".pickleball"); + writeComplete(pickleball, "2.0.0", "guide-two"); + PickleballLocalLayout.writeCurrent(pickleball, PickleballLocalLayout.CurrentPointer.completeNow("2.0.0")); + Output missing = run("use-version", "--version=9.9.9", tempDir.toString()); + assertEquals(1, missing.exitCode()); + assertTrue(missing.stderr().contains("export-guidance from Pickleball 9.9.9 is required")); + assertEquals("2.0.0", PickleballLocalLayout.readCurrent(pickleball).orElseThrow().pickleballVersion()); + } + + @Test + void gcVersionsKeepsTodayAndTheCurrentPointerAndRemovesAnUnusedTree() throws Exception { + Path pickleball = tempDir.resolve(".pickleball"); + writeComplete(pickleball, "2.0.0", "current-guide"); + writeComplete(pickleball, "1.9.0", "recent-guide"); + writeComplete(pickleball, "1.8.0", "live-guide"); + writeComplete(pickleball, "1.7.0", "expired-guide"); + writeComplete(pickleball, "1.6.0", "unmarked-guide"); + PickleballLocalLayout.writeCurrent(pickleball, PickleballLocalLayout.CurrentPointer.completeNow("2.0.0")); + Instant now = Instant.parse("2026-10-06T12:00:00Z"); + PickleballLocalLayout.writeLastUsed(pickleball.resolve("v/2.0.0"), now.minus(AgentCoordination.VERSION_UNUSED)); + PickleballLocalLayout.writeLastUsed(pickleball.resolve("v/1.9.0"), now); + PickleballLocalLayout.writeLastUsed(pickleball.resolve("v/1.8.0"), now.minus(AgentCoordination.VERSION_UNUSED).minusSeconds(5)); + PickleballLocalLayout.writeLastUsed(pickleball.resolve("v/1.7.0"), now.minus(AgentCoordination.VERSION_UNUSED)); + Files.createDirectories(pickleball.resolve("v/1.8.0/workbench")); + Files.writeString(pickleball.resolve("v/1.8.0/workbench/cli-session.json"), """ + {"pid": %d, "mode": "cli-session"} + """.formatted(ProcessHandle.current().pid())); + Files.createDirectories(pickleball.resolve("investigations/keep")); + Files.writeString(pickleball.resolve("investigations/keep/report.html"), "

keep

\n"); + Files.createDirectories(pickleball.resolve("v/1.7.0/investigations/legacy")); + Files.writeString(pickleball.resolve("v/1.7.0/investigations/legacy/investigation.json"), "{\"id\":\"legacy\"}\n"); + Files.writeString(pickleball.resolve("history.log"), "stay\n"); + Files.createDirectories(pickleball.resolve("runs/run-old")); + Files.writeString(pickleball.resolve("runs/run-old/record.json"), "{\"runId\":\"run-old\"}\n"); + + AgentCoordination.VersionSweep sweep = AgentCoordination.sweepVersions(tempDir, now); + assertEquals(1, sweep.versionsRemoved()); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.0.0/AGENT-GUIDE.md"))); + assertTrue(Files.isRegularFile(pickleball.resolve("v/1.9.0/AGENT-GUIDE.md"))); + assertTrue(Files.isRegularFile(pickleball.resolve("v/1.8.0/AGENT-GUIDE.md"))); + assertTrue(Files.isRegularFile(pickleball.resolve("v/1.6.0/AGENT-GUIDE.md"))); + assertFalse(Files.exists(pickleball.resolve("v/1.7.0/AGENT-GUIDE.md"))); + assertFalse(Files.exists(pickleball.resolve("v/1.7.0/workbench"))); + assertTrue(Files.isRegularFile(pickleball.resolve("v/1.7.0/investigations/legacy/investigation.json"))); + assertTrue(Files.isRegularFile(pickleball.resolve("investigations/keep/report.html"))); + assertTrue(Files.isRegularFile(pickleball.resolve("runs/run-old/record.json"))); + assertEquals("stay\n", Files.readString(pickleball.resolve("history.log"))); + assertEquals(0, AgentCoordination.sweepVersions(tempDir, now).versionsRemoved()); + } + + @Test + void aRunningRunKeepsItsVersionAndASwitchBackRefreshesLastUsed() throws Exception { + Path pickleball = tempDir.resolve(".pickleball"); + writeComplete(pickleball, "2.0.0", "guide-two"); + writeComplete(pickleball, "2.1.0", "guide-two-one"); + PickleballLocalLayout.writeCurrent(pickleball, PickleballLocalLayout.CurrentPointer.completeNow("2.0.0")); + AgentCoordination.Run running = AgentCoordination.begin( + tempDir, + AgentCoordination.Request.of("run-pinned", "agent-pin", null, null, null, null, "discover") + ); + assertEquals(0, run("use-version", "--version=2.1.0", tempDir.toString()).exitCode()); + Instant stale = Instant.now().minus(AgentCoordination.VERSION_UNUSED).minusSeconds(30); + PickleballLocalLayout.writeLastUsed(pickleball.resolve("v/2.0.0"), stale); + AgentCoordination.sweepVersions(tempDir, Instant.now()); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.0.0/AGENT-GUIDE.md"))); + assertTrue(Files.readString(running.recordFile()).contains("\"pickleballVersion\" : \"2.0.0\"")); + assertTrue(Files.readString(running.recordFile()).contains("RUNNING")); + + assertEquals(0, run("use-version", "--version=2.0.0", tempDir.toString()).exitCode()); + Instant used = PickleballLocalLayout.readLastUsed(pickleball.resolve("v/2.0.0")).orElseThrow(); + assertTrue(used.isAfter(stale)); + assertEquals(0, run("use-version", "--version=2.1.0", tempDir.toString()).exitCode()); + AgentCoordination.sweepVersions(tempDir, Instant.now()); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.0.0/AGENT-GUIDE.md"))); + assertTrue(Files.isRegularFile(pickleball.resolve("v/2.1.0/AGENT-GUIDE.md"))); + } + + private static void writeComplete(Path pickleball, String version, String marker) throws Exception { + Path root = pickleball.resolve("v").resolve(version); + Files.createDirectories(root.resolve("open")); + Files.createDirectories(root.resolve("workbench/controller")); + Files.writeString(root.resolve("AGENT-GUIDE.md"), marker + "\n"); + Files.writeString(root.resolve("GUIDANCE-MANIFEST.json"), """ + { + "pickleballVersion": "%s", + "files": ["AGENT-GUIDE.md"] + } + """.formatted(version)); + Files.writeString(root.resolve("open/pickleball-workbench.sh"), "#!/bin/sh\n# open-" + version + "\n"); + Files.writeString(root.resolve("workbench/controller/keep.jar"), "jar"); + } + + private Output run(String... args) { + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + int exit = WorkbenchAgentCommands.run( + args, + new PrintStream(stdout, true, StandardCharsets.UTF_8), + new PrintStream(stderr, true, StandardCharsets.UTF_8) + ); + return new Output(exit, stdout.toString(StandardCharsets.UTF_8), stderr.toString(StandardCharsets.UTF_8)); + } + + private record Output(int exitCode, String stdout, String stderr) { + } +} diff --git a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java index c9c5874d4..0350db1eb 100644 --- a/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java +++ b/src/test/java/tools/dscode/launcher/WorkbenchCommandLineTest.java @@ -132,6 +132,13 @@ void coordinationFlagsAreNotForwardedAndDoNotJoinTheName() { assertTrue(WorkbenchCommandLine.isAgentCoreCommand("post")); assertTrue(WorkbenchCommandLine.isAgentCoreCommand("history")); assertTrue(WorkbenchCommandLine.isAgentCoreCommand("gc-runs")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("gc-versions")); + assertTrue(WorkbenchCommandLine.isAgentCoreCommand("use-version")); + WorkbenchCommandLine.Parsed switched = WorkbenchCommandLine.parse(new String[]{ + "use-version", "--version=2.1.14" + }); + assertEquals("use-version", switched.command()); + assertEquals("2.1.14", switched.version()); } @Test