From f5b44f44189aba7c653f40dc73cf720416368654 Mon Sep 17 00:00:00 2001 From: Emmanuel Delgado Date: Sat, 28 Mar 2026 12:20:44 -0400 Subject: [PATCH] Add ability to have a pre-requisite run to our trail config --- ...ail_prerequisites_feature_6230e625.plan.md | 95 ++++++ .../host/rules/BaseHostTrailblazeTest.kt | 149 +++++++++ .../ui/tabs/sessions/YamlTabComposables.kt | 56 ++-- .../tabs/trails/TrailsBrowserTabComposable.kt | 85 +++-- .../trailblaze/logs/client/TrailblazeLog.kt | 38 +++ .../xyz/block/trailblaze/yaml/TrailConfig.kt | 8 + .../report/models/SessionSummary.kt | 4 + .../mcp/newtools/TrailFileManager.kt | 294 +++++++++++++++++- .../trailblaze/mcp/newtools/TrailMcpTool.kt | 256 ++++++++++++++- .../mcp/utils/McpProgressNotifier.kt | 15 + .../ui/tabs/session/LogCardComposable.kt | 20 ++ .../trailblaze/ui/tabs/session/LogListRow.kt | 12 + .../ui/tabs/session/SessionProgressHelpers.kt | 5 + .../ui/tabs/session/SessionTimelineView.kt | 2 + .../tabs/session/group/FlatLogComposable.kt | 54 ++++ .../ui/tabs/session/group/LogDetailsDialog.kt | 12 + .../block/trailblaze/ui/utils/ColorUtils.kt | 2 + .../block/trailblaze/ui/utils/DisplayUtils.kt | 2 + .../ui/editors/yaml/YamlVisualEditor.kt | 121 +++++++ .../block/trailblaze/ui/tabs/trails/Trail.kt | 6 + .../ui/tabs/trails/TrailDetailsView.kt | 14 + .../ui/tabs/trails/TrailYamlEditorModal.kt | 2 + 22 files changed, 1194 insertions(+), 58 deletions(-) create mode 100644 .firebender/plans/trail_prerequisites_feature_6230e625.plan.md diff --git a/.firebender/plans/trail_prerequisites_feature_6230e625.plan.md b/.firebender/plans/trail_prerequisites_feature_6230e625.plan.md new file mode 100644 index 000000000..f9f7121ef --- /dev/null +++ b/.firebender/plans/trail_prerequisites_feature_6230e625.plan.md @@ -0,0 +1,95 @@ + + +# Trail Prerequisites Feature - Handoff Notes + +## Current State (What is already true) +- `TrailConfig.prerequisites` is already implemented and editable in the visual config editor. +- MCP run path (`TrailMcpTool.handleRun`) already executes prerequisites before the main trail. +- Prerequisite log types exist (`PrerequisiteStartLog`, `PrerequisiteCompleteLog`) and are handled by timeline/event summary UI. +- `TrailDetailsView` now surfaces prerequisites in the details panel. + +## Root Cause of "it doesn't run from UI" +The user-reported mismatch is real: +- **Works** when running via MCP `trail(action=RUN)`. +- **Does not auto-run prerequisites** when running from Desktop UI tabs. + +Why: +- Desktop UI uses `DesktopYamlRunner` -> `TrailblazeHostYamlRunner` with raw `runYamlRequest.yaml`. +- That path bypasses `TrailMcpTool.handleRun()` where prerequisite orchestration lives. + +## Implementation Direction Chosen +To make Desktop UI run behavior match MCP behavior, we are flattening prerequisites into the YAML before dispatching host/on-device execution. + +### Added utility (in progress) +- `TrailFileManager.flattenPrerequisites(yamlContent: String, variantFilePath: String? = null): String` +- Intended behavior: + - Parse YAML. + - Resolve effective config (including NL definition config via `variantFilePath`). + - Resolve prerequisite IDs using `findTrailByName`. + - Collect prerequisite prompt steps (including nested prerequisites) and prepend to main steps. + - Return re-encoded YAML for normal runner pipeline. + +## Required Next Edits + +### 1) Wire into Trails Browser run path +File: `TrailsBrowserTabComposable.kt` +- Before `requestFactory.create(...)`, transform YAML: + - Input: `yamlContentToRun!!` + - Output: `flattenedYaml` +- Use `flattenedYaml` in request creation. +- Pass selected variant absolute path as `variantFilePath` when available for proper `blaze.yaml` prerequisite resolution. + +### 2) Wire into Session/YAML tab run path +File: `YamlTabComposables.kt` +- Apply same flattening before invoking runner. +- Keep behavior consistent with Trails Browser tab. + +### 3) Validate/fix flattening utility details +File: `TrailFileManager.kt` +- Ensure recursion handles cycles safely (visited set), avoiding infinite recursion. +- Ensure deterministic ordering of prerequisites. +- Ensure config output does not retain unresolved `prerequisites` after flattening. +- Preserve non-prompt items correctly. + +## Testing Matrix (must pass) +1. Run trail with no prerequisites (no behavior change). +2. Run trail with one prerequisite from Trails tab. +3. Run trail with one prerequisite from Session/YAML tab. +4. Run trail with nested prerequisites (`A -> B -> C`). +5. Run trail with missing prerequisite ID (clear error/warning behavior). +6. Run trail with circular prerequisite graph (must fail safely, no hang). +7. Confirm MCP run path still works unchanged. + +## Optional Follow-up (if UX parity desired) +Desktop flattened execution may not emit explicit prerequisite start/complete logs like MCP path. If parity is desired in session timeline, add explicit logging in host run path around flattened prerequisite boundaries. + +## Key Files for Next Agent +- `trailblaze-server/.../TrailMcpTool.kt` (MCP behavior reference) +- `trailblaze-server/.../TrailFileManager.kt` (flatten helper + prerequisite resolution) +- `trailblaze-host/.../ui/tabs/trails/TrailsBrowserTabComposable.kt` (Desktop Trails tab run entry) +- `trailblaze-host/.../ui/tabs/sessions/YamlTabComposables.kt` (Desktop YAML/session run entry) +- `trailblaze-host/.../host/yaml/DesktopYamlRunner.kt` (Desktop execution pipeline) +- `trailblaze-host/.../host/TrailblazeHostYamlRunner.kt` (host execution dispatch) diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt index c04131022..685de162e 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/rules/BaseHostTrailblazeTest.kt @@ -47,6 +47,7 @@ import xyz.block.trailblaze.mcp.sampling.LocalLlmSamplingSource import xyz.block.trailblaze.model.TrailblazeConfig import xyz.block.trailblaze.model.TrailblazeHostAppTarget import xyz.block.trailblaze.recordings.TrailRecordings +import java.io.File import xyz.block.trailblaze.rules.RetryRule import xyz.block.trailblaze.rules.TrailblazeLoggingRule import xyz.block.trailblaze.rules.TrailblazeRunnerUtil @@ -57,6 +58,7 @@ import xyz.block.trailblaze.toolcalls.TrailblazeToolResult import xyz.block.trailblaze.toolcalls.TrailblazeToolSet import xyz.block.trailblaze.util.Console import xyz.block.trailblaze.util.TemplatingUtil +import xyz.block.trailblaze.yaml.TrailConfig import xyz.block.trailblaze.yaml.TrailYamlItem import xyz.block.trailblaze.yaml.createTrailblazeYaml import kotlin.reflect.KClass @@ -360,6 +362,146 @@ abstract class BaseHostTrailblazeTest( ) } + /** + * Loads and executes prerequisite trails before the main trail. + * + * For each prerequisite ID, locates its trail directory (sibling to the current trail), + * loads the NL definition file, decodes it, and runs it. Emits PrerequisiteStart/Complete + * logs so the session detail page shows prerequisite execution. + */ + private suspend fun runPrerequisiteTrails( + prerequisiteIds: List, + trailFilePath: String?, + currentTrailId: String, + useRecordedSteps: Boolean, + visited: MutableSet = mutableSetOf(), + ) { + if (prerequisiteIds.isEmpty() || trailFilePath == null) return + visited.add(currentTrailId) + + // Derive the trails root directory from the current trail's file path. + // Trail files live at: //blaze.yaml (or *.trail.yaml) + val trailFile = File(trailFilePath) + val trailDir = trailFile.parentFile ?: return + val trailsRootDir = trailDir.parentFile ?: return + + val session = loggingRule.session + + for (prereqId in prerequisiteIds) { + val prereqDir = File(trailsRootDir, prereqId) + if (!prereqDir.isDirectory) { + Console.log("⚠️ Prerequisite trail directory not found: $prereqId") + continue + } + + // Find the NL definition file (blaze.yaml or trailblaze.yaml) + val prereqYamlFile = TrailRecordings.NL_DEFINITION_FILENAMES + .map { File(prereqDir, it) } + .firstOrNull { it.exists() } + + if (prereqYamlFile == null) { + Console.log("⚠️ No NL definition file found for prerequisite: $prereqId") + continue + } + + val prereqYaml: String + val prereqItems: List + val prereqConfig: TrailConfig? + try { + prereqYaml = prereqYamlFile.readText() + prereqItems = trailblazeYaml.decodeTrail(prereqYaml) + prereqConfig = trailblazeYaml.extractTrailConfig(prereqItems) + } catch (e: Exception) { + Console.log("⚠️ Failed to load prerequisite '$prereqId': ${e.message}") + continue + } + + // Recursively run nested prerequisites before this one + val nestedPrereqs = prereqConfig?.prerequisites.orEmpty().filter { it !in visited } + if (nestedPrereqs.isNotEmpty()) { + runPrerequisiteTrails( + prerequisiteIds = nestedPrereqs, + trailFilePath = prereqYamlFile.absolutePath, + currentTrailId = prereqId, + useRecordedSteps = useRecordedSteps, + visited = visited, + ) + } + + val prereqTitle = prereqConfig?.title + val totalStepCount = prereqItems.filterIsInstance() + .sumOf { it.promptSteps.size } + + // Emit prerequisite start log + if (session != null) { + loggingRule.logger.log( + session, + TrailblazeLog.PrerequisiteStartLog( + prerequisiteTrailId = prereqId, + prerequisiteTitle = prereqTitle, + parentTrailId = currentTrailId, + session = session.sessionId, + timestamp = Clock.System.now(), + ), + ) + } + + Console.log("▶️ Running prerequisite: ${prereqTitle ?: prereqId} ($totalStepCount steps)") + val startTime = Clock.System.now() + var passed = true + var failureReason: String? = null + var stepsExecuted = 0 + + try { + for (item in prereqItems) { + val itemResult = when (item) { + is TrailYamlItem.PromptsTrailItem -> { + trailblazeRunnerUtil.runPromptSuspend(item.promptSteps, useRecordedSteps) + } + is TrailYamlItem.ToolTrailItem -> trailblazeRunnerUtil.runTrailblazeTool(item.tools.map { it.trailblazeTool }) + is TrailYamlItem.ConfigTrailItem -> item.config.context?.let { trailblazeRunner.appendToSystemPrompt(it) } + } + if (itemResult is TrailblazeToolResult.Error) { + throw TrailblazeException(itemResult.errorMessage) + } + if (item is TrailYamlItem.PromptsTrailItem) { + stepsExecuted += item.promptSteps.size + } + } + } catch (e: Exception) { + passed = false + failureReason = e.message + Console.log("❌ Prerequisite '$prereqId' failed: ${e.message}") + } + + val durationMs = (Clock.System.now() - startTime).inWholeMilliseconds + + // Emit prerequisite complete log + if (session != null) { + loggingRule.logger.log( + session, + TrailblazeLog.PrerequisiteCompleteLog( + prerequisiteTrailId = prereqId, + prerequisiteTitle = prereqTitle, + parentTrailId = currentTrailId, + passed = passed, + stepsExecuted = stepsExecuted, + durationMs = durationMs, + failureReason = failureReason, + session = session.sessionId, + timestamp = Clock.System.now(), + ), + ) + } + + if (passed) { + Console.log("✅ Prerequisite '$prereqId' passed (${durationMs}ms)") + } else { + throw TrailblazeException("Prerequisite trail '$prereqId' failed: $failureReason") + } + } + } + /** * Suspend version of runTrail that checks for coroutine cancellation. * This allows proper cancellation propagation when running in a coroutine context. @@ -422,6 +564,13 @@ abstract class BaseHostTrailblazeTest( ) } } + + // Execute prerequisite trails before the main trail + val prerequisiteIds = trailConfig?.prerequisites.orEmpty() + val currentTrailId = trailConfig?.id ?: trailFilePath?.let { File(it).parentFile?.name } ?: "unknown" + runPrerequisiteTrails(prerequisiteIds, trailFilePath, currentTrailId, useRecordedSteps) + + // Run the main trail runTrail(trailItems, useRecordedSteps) return loggingRule.session?.sessionId ?: SessionId("unknown") } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/sessions/YamlTabComposables.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/sessions/YamlTabComposables.kt index 96b2a26c7..245634209 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/sessions/YamlTabComposables.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/sessions/YamlTabComposables.kt @@ -41,8 +41,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import xyz.block.trailblaze.devices.TrailblazeConnectedDeviceSummary import xyz.block.trailblaze.llm.TrailblazeReferrer import xyz.block.trailblaze.model.DesktopAppRunYamlParams @@ -57,6 +59,8 @@ import xyz.block.trailblaze.ui.editors.yaml.YamlEditorMode import xyz.block.trailblaze.ui.editors.yaml.YamlTextEditor import xyz.block.trailblaze.ui.editors.yaml.YamlVisualEditor import xyz.block.trailblaze.ui.editors.yaml.YamlVisualEditorView +import xyz.block.trailblaze.mcp.newtools.TrailFileManager +import xyz.block.trailblaze.ui.TrailblazeDesktopUtil import xyz.block.trailblaze.ui.editors.yaml.validateYaml /** @@ -76,6 +80,8 @@ fun YamlTabComposable( additionalInstrumentationArgs: (suspend () -> Map), ) { val serverState by trailblazeSettingsRepo.serverStateFlow.collectAsState() + val trailsDir = TrailblazeDesktopUtil.getEffectiveTrailsDirectory(serverState.appConfig) + val trailFileManager = remember(trailsDir) { TrailFileManager(trailsDir) } val savedYamlContent = serverState.appConfig.yamlContent val savedEditorMode = serverState.appConfig.yamlEditorMode val savedVisualEditorView = serverState.appConfig.yamlVisualEditorView @@ -287,29 +293,37 @@ fun YamlTabComposable( } val targetTestApp = deviceManager.getCurrentSelectedTargetApp() - // Run on each selected device - selectedDevices.forEach { device -> - val runYamlRequest = requestFactory.create( - device = device, - yaml = localYamlContent, - testName = "Yaml", - referrer = TrailblazeReferrer.YAML_TAB, - ) - coroutineScope.launch { - try { - yamlRunner( - DesktopAppRunYamlParams( - forceStopTargetApp = forceStopApp, - runYamlRequest = runYamlRequest, - onProgressMessage = onProgressMessage, - onConnectionStatus = onConnectionStatus, - targetTestApp = targetTestApp, - additionalInstrumentationArgs = additionalInstrumentationArgs() + coroutineScope.launch { + // Flatten prerequisites into YAML so Desktop UI runs them like MCP does + val flattenedYaml = withContext(Dispatchers.IO) { + trailFileManager.flattenPrerequisites(localYamlContent) + } + + // Run on each selected device + selectedDevices.forEach { device -> + val runYamlRequest = requestFactory.create( + device = device, + yaml = flattenedYaml, + testName = "Yaml", + referrer = TrailblazeReferrer.YAML_TAB, + ) + + launch { + try { + yamlRunner( + DesktopAppRunYamlParams( + forceStopTargetApp = forceStopApp, + runYamlRequest = runYamlRequest, + onProgressMessage = onProgressMessage, + onConnectionStatus = onConnectionStatus, + targetTestApp = targetTestApp, + additionalInstrumentationArgs = additionalInstrumentationArgs() + ) ) - ) - } catch (e: Exception) { - onProgressMessage("Error on device ${device.instanceId}: ${e.message}") + } catch (e: Exception) { + onProgressMessage("Error on device ${device.instanceId}: ${e.message}") + } } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/trails/TrailsBrowserTabComposable.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/trails/TrailsBrowserTabComposable.kt index 299b01ce8..e008d7140 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/trails/TrailsBrowserTabComposable.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/trails/TrailsBrowserTabComposable.kt @@ -82,6 +82,7 @@ import xyz.block.trailblaze.ui.tabs.trails.TrailDetailsView import xyz.block.trailblaze.ui.tabs.trails.TrailVariant import xyz.block.trailblaze.ui.tabs.trails.TrailYamlEditorModal import xyz.block.trailblaze.ui.tabs.trails.TrailsDirectoryScanner +import xyz.block.trailblaze.mcp.newtools.TrailFileManager import xyz.block.trailblaze.yaml.TrailSourceType import java.io.File import java.nio.file.Paths @@ -120,6 +121,7 @@ fun TrailsBrowserTabComposable( onChangeDirectory: ((String) -> Unit)? = null, ) { val trailsDirectory = remember(trailsDirectoryPath) { File(trailsDirectoryPath) } + val trailFileManager = remember(trailsDirectoryPath) { TrailFileManager(trailsDirectoryPath) } val serverState by trailblazeSettingsRepo.serverStateFlow.collectAsState() val coroutineScope = rememberCoroutineScope() @@ -127,6 +129,7 @@ fun TrailsBrowserTabComposable( var showDeviceSelectionDialog by remember { mutableStateOf(false) } var trailNameToRun by remember { mutableStateOf(null) } var yamlContentToRun by remember { mutableStateOf(null) } + var variantFilePathToRun by remember { mutableStateOf(null) } // Progress state for trail execution var progressMessages by remember { mutableStateOf>(emptyList()) } @@ -945,13 +948,15 @@ fun TrailsBrowserTabComposable( // Show device selection dialog to run the trail trailNameToRun = yamlViewerVariant?.displayLabel ?: "Trail" yamlContentToRun = yamlViewerContent + variantFilePathToRun = yamlViewerVariant?.absolutePath showDeviceSelectionDialog = true }, progressMessages = progressMessages, connectionStatus = connectionStatus, relativePath = runCatching { Paths.get(trailsDirectory.absolutePath).relativize(Paths.get(yamlViewerVariant!!.absolutePath)).toString() - }.getOrElse { yamlViewerVariant!!.fileName } + }.getOrElse { yamlViewerVariant!!.fileName }, + availableTrailIds = remember(trails) { trails.map { it.id } } ) } @@ -973,6 +978,7 @@ fun TrailsBrowserTabComposable( showDeviceSelectionDialog = false trailNameToRun = null yamlContentToRun = null + variantFilePathToRun = null }, onSessionClick = { sessionId -> showDeviceSelectionDialog = false @@ -983,43 +989,58 @@ fun TrailsBrowserTabComposable( connectionStatus = null val targetTestApp = deviceManager.getCurrentSelectedTargetApp() + val yamlContent = yamlContentToRun!! + val variantFilePath = variantFilePathToRun + val trailName = trailNameToRun - // Run on each selected device - selectedDevices.forEach { device -> - val runYamlRequest = requestFactory.create( - device = device, - yaml = yamlContentToRun!!, - testName = trailNameToRun ?: "Trail from Browser", - referrer = TrailblazeReferrer(id = "trails_tab", display = "Trails Tab"), - ) + trailNameToRun = null + yamlContentToRun = null + variantFilePathToRun = null - coroutineScope.launch(Dispatchers.IO) { - try { - yamlRunner( - DesktopAppRunYamlParams( - forceStopTargetApp = forceStopApp, - runYamlRequest = runYamlRequest, - onProgressMessage = { message -> - progressMessages = progressMessages + message - }, - onConnectionStatus = { status -> - connectionStatus = status - }, - targetTestApp = targetTestApp, - additionalInstrumentationArgs = additionalInstrumentationArgs(), - onComplete = { result -> - // Stay in the current session view - don't navigate away - // The session detail view will show the completed state - } + coroutineScope.launch { + // Flatten prerequisites into YAML so Desktop UI runs them like MCP does + val flattenedYaml = withContext(Dispatchers.IO) { + trailFileManager.flattenPrerequisites( + yamlContent = yamlContent, + variantFilePath = variantFilePath, + ) + } + + // Run on each selected device + selectedDevices.forEach { device -> + val runYamlRequest = requestFactory.create( + device = device, + yaml = flattenedYaml, + testName = trailName ?: "Trail from Browser", + referrer = TrailblazeReferrer(id = "trails_tab", display = "Trails Tab"), + ) + + launch(Dispatchers.IO) { + try { + yamlRunner( + DesktopAppRunYamlParams( + forceStopTargetApp = forceStopApp, + runYamlRequest = runYamlRequest, + onProgressMessage = { message -> + progressMessages = progressMessages + message + }, + onConnectionStatus = { status -> + connectionStatus = status + }, + targetTestApp = targetTestApp, + additionalInstrumentationArgs = additionalInstrumentationArgs(), + onComplete = { result -> + // Stay in the current session view - don't navigate away + // The session detail view will show the completed state + } + ) ) - ) - } catch (e: Exception) { - progressMessages = progressMessages + "Error: ${e.message}" + } catch (e: Exception) { + progressMessages = progressMessages + "Error: ${e.message}" + } } } } - trailNameToRun = null - yamlContentToRun = null } ) } diff --git a/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLog.kt b/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLog.kt index 1bc0397c6..94f7c56da 100644 --- a/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLog.kt +++ b/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/logs/client/TrailblazeLog.kt @@ -224,6 +224,44 @@ sealed interface TrailblazeLog { ) : TrailblazeLog, HasPromptStep + /** + * Log entry emitted when a prerequisite trail begins execution. + */ + @Serializable + data class PrerequisiteStartLog( + /** The trail ID of the prerequisite being executed */ + val prerequisiteTrailId: String, + /** The title of the prerequisite trail, if available */ + val prerequisiteTitle: String? = null, + /** The trail ID of the trail that requires this prerequisite */ + val parentTrailId: String, + override val session: SessionId, + override val timestamp: Instant, + ) : TrailblazeLog + + /** + * Log entry emitted when a prerequisite trail finishes execution. + */ + @Serializable + data class PrerequisiteCompleteLog( + /** The trail ID of the prerequisite that was executed */ + val prerequisiteTrailId: String, + /** The title of the prerequisite trail, if available */ + val prerequisiteTitle: String? = null, + /** The trail ID of the trail that requires this prerequisite */ + val parentTrailId: String, + /** Whether the prerequisite trail passed */ + val passed: Boolean, + /** Number of steps executed in the prerequisite trail */ + val stepsExecuted: Int, + /** Execution duration in milliseconds */ + val durationMs: Long, + /** Failure reason if the prerequisite failed */ + val failureReason: String? = null, + override val session: SessionId, + override val timestamp: Instant, + ) : TrailblazeLog + @Serializable data class AttemptAiFallbackLog( override val promptStep: PromptStep, diff --git a/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/yaml/TrailConfig.kt b/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/yaml/TrailConfig.kt index 911199177..4db754ea9 100644 --- a/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/yaml/TrailConfig.kt +++ b/trailblaze-models/src/commonMain/kotlin/xyz/block/trailblaze/yaml/TrailConfig.kt @@ -33,6 +33,14 @@ data class TrailConfig( val driver: String? = null, /** Optional Electron app configuration for [TrailblazeDriverType.PLAYWRIGHT_ELECTRON] trails. */ val electron: ElectronAppConfig? = null, + /** + * Optional list of prerequisite trail IDs that must run before this trail. + * Each entry is the ID of another trail that will be auto-executed prior to this trail. + * Nested prerequisites (prerequisites of prerequisites) are resolved recursively + * across all execution paths (Desktop UI, host tests, MCP). A visited set prevents + * circular dependencies. + */ + val prerequisites: List? = null, ) @Serializable diff --git a/trailblaze-report/src/main/java/xyz/block/trailblaze/report/models/SessionSummary.kt b/trailblaze-report/src/main/java/xyz/block/trailblaze/report/models/SessionSummary.kt index 78551916f..956b15e19 100644 --- a/trailblaze-report/src/main/java/xyz/block/trailblaze/report/models/SessionSummary.kt +++ b/trailblaze-report/src/main/java/xyz/block/trailblaze/report/models/SessionSummary.kt @@ -90,6 +90,8 @@ data class SessionSummary( is TrailblazeLog.McpToolCallResponseLog, is TrailblazeLog.TrailblazeProgressLog, is TrailblazeLog.McpAskLog, + is TrailblazeLog.PrerequisiteStartLog, + is TrailblazeLog.PrerequisiteCompleteLog, -> it } }.sortedBy { log -> log.timestamp } @@ -245,6 +247,8 @@ data class SessionSummary( is TrailblazeLog.McpToolCallResponseLog, is TrailblazeLog.TrailblazeProgressLog, is TrailblazeLog.McpAskLog, + is TrailblazeLog.PrerequisiteStartLog, + is TrailblazeLog.PrerequisiteCompleteLog, -> null } } diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailFileManager.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailFileManager.kt index c630d2e20..96960782c 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailFileManager.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailFileManager.kt @@ -17,6 +17,7 @@ import xyz.block.trailblaze.yaml.VerificationStep import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import xyz.block.trailblaze.logs.client.temp.OtherTrailblazeTool +import xyz.block.trailblaze.recordings.TrailRecordings import java.io.File /** @@ -108,7 +109,8 @@ class TrailFileManager( dir.mkdirs() } - // Build trail YAML items + // Build trail YAML items (prerequisite validation happens at the MCP tool layer + // since saveTrail builds its own config without prerequisites) val trailItems = buildTrailYamlItems(name, steps, platform, metadata) // Encode to YAML @@ -213,9 +215,19 @@ class TrailFileManager( } catch (_: IllegalArgumentException) { null } } - // Try directory with any platform variant + // Try directory with NL definition file (blaze.yaml / trailblaze.yaml) val trailDir = File(dir, name) if (trailDir.exists() && trailDir.isDirectory) { + for (nlFileName in TrailRecordings.NL_DEFINITION_FILENAMES) { + val nlFile = File(trailDir, nlFileName) + if (nlFile.exists()) { + return try { + validateWithinTrailsDir(nlFile, name).absolutePath + } catch (_: IllegalArgumentException) { null } + } + } + + // Try directory with any platform variant trailDir.listFiles()?.firstOrNull { it.name.endsWith(".trail.yaml") } ?.let { return try { @@ -332,6 +344,275 @@ class TrailFileManager( } } + /** + * Loads the NL definition config (from blaze.yaml / trailblaze.yaml) for a trail directory. + * This is useful when a variant file (e.g., android.trail.yaml) is loaded but its config + * may not contain all fields (like prerequisites) that are defined in the NL definition file. + * + * @param variantFilePath Path to any file within the trail directory + * @return The TrailConfig from the NL definition file, or null if not found + */ + fun loadNlDefinitionConfig(variantFilePath: String): TrailConfig? { + val parentDir = File(variantFilePath).parentFile ?: return null + for (nlFileName in TrailRecordings.NL_DEFINITION_FILENAMES) { + val nlFile = File(parentDir, nlFileName) + if (nlFile.exists()) { + return try { + val yamlContent = nlFile.readText() + trailblazeYaml.extractTrailConfig(yamlContent) + } catch (_: Exception) { + null + } + } + } + return null + } + + /** + * Returns the effective config for a trail, merging the variant config with the NL definition + * config. The variant config takes precedence, but NL definition config fills in missing fields + * (like prerequisites). + * + * @param variantFilePath Path to the variant trail file + * @param variantConfig Config extracted from the variant file (may be null) + * @return Merged config with prerequisites from NL definition if not set in variant + */ + fun getEffectiveConfig(variantFilePath: String, variantConfig: TrailConfig?): TrailConfig? { + val nlConfig = loadNlDefinitionConfig(variantFilePath) + if (nlConfig == null) return variantConfig + if (variantConfig == null) return nlConfig + + // Merge: variant config takes precedence, NL config fills in missing fields + return variantConfig.copy( + prerequisites = variantConfig.prerequisites ?: nlConfig.prerequisites, + id = variantConfig.id ?: nlConfig.id, + title = variantConfig.title ?: nlConfig.title, + description = variantConfig.description ?: nlConfig.description, + priority = variantConfig.priority ?: nlConfig.priority, + context = variantConfig.context ?: nlConfig.context, + source = variantConfig.source ?: nlConfig.source, + metadata = if (variantConfig.metadata != null) variantConfig.metadata else nlConfig.metadata, + app = variantConfig.app ?: nlConfig.app, + platform = variantConfig.platform ?: nlConfig.platform, + driver = variantConfig.driver ?: nlConfig.driver, + ) + } + + // ───────────────────────────────────────────────────────────────────────────── + // Prerequisite validation + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Validates that all prerequisite trail IDs in [config] resolve to existing trail files. + * + * @param config The trail config whose prerequisites should be validated + * @return A list of validation error strings. Empty list means all prerequisites are valid. + */ + fun validatePrerequisites(config: TrailConfig?): List { + val prerequisites = config?.prerequisites + if (prerequisites.isNullOrEmpty()) return emptyList() + + val errors = mutableListOf() + for (prereqId in prerequisites) { + if (findTrailByName(prereqId) == null) { + errors.add("Prerequisite trail '$prereqId' not found") + } + } + return errors + } + + /** + * Detects circular dependencies between trails using a full depth-first traversal. + * + * Checks whether [targetTrailId] adding [newPrerequisites] would create a cycle, + * including transitive chains (e.g., A → B → C → A). + * + * @param targetTrailId The ID of the trail being updated + * @param newPrerequisites The prerequisite IDs to set + * @return A circular dependency error message, or null if no cycle detected + */ + fun detectCircularDependency(targetTrailId: String, newPrerequisites: List): String? { + // Direct self-reference + if (targetTrailId in newPrerequisites) { + return "Circular dependency: trail '$targetTrailId' cannot be a prerequisite of itself" + } + + // DFS through the full prerequisite graph looking for a path back to targetTrailId + val visited = mutableSetOf() + val stack = ArrayDeque(newPrerequisites) + + while (stack.isNotEmpty()) { + val prereqId = stack.removeLast() + if (!visited.add(prereqId)) continue + + val prereqFile = findTrailByName(prereqId) ?: continue + val prereqConfig = getEffectiveConfig(prereqFile, getTrailInfo(prereqFile)?.first) + val transitiveDeps = prereqConfig?.prerequisites ?: continue + + for (dep in transitiveDeps) { + if (dep == targetTrailId) { + return "Circular dependency: trail '$targetTrailId' is reachable through '$prereqId' → '$dep'" + } + if (dep !in visited) { + stack.addLast(dep) + } + } + } + + return null + } + + // ───────────────────────────────────────────────────────────────────────────── + // Prerequisite flattening + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Flattens prerequisite trail steps into the given YAML content. + * + * When a trail has `prerequisites: [trailA, trailB]` in its config, this method: + * 1. Loads each prerequisite trail file + * 2. Extracts their prompt steps + * 3. Prepends them before the main trail's steps + * 4. Returns the combined YAML content + * + * This is used by the Desktop UI to ensure prerequisite steps are executed as part + * of the same session when running a trail from the trails browser or YAML editor. + * + * If no prerequisites are defined, or if prerequisite resolution fails, the original + * YAML content is returned unchanged. + * + * @param yamlContent The YAML content of the main trail + * @param variantFilePath Optional file path of the trail variant (used to resolve + * NL definition config for prerequisites defined in blaze.yaml) + * @return Flattened YAML with prerequisite steps prepended, or original content if + * no prerequisites exist + */ + fun flattenPrerequisites( + yamlContent: String, + variantFilePath: String? = null, + ): String { + return flattenPrerequisitesRecursive(yamlContent, variantFilePath, mutableSetOf()) + } + + private fun flattenPrerequisitesRecursive( + yamlContent: String, + variantFilePath: String? = null, + visited: MutableSet, + ): String { + val trailItems = try { + trailblazeYaml.decodeTrail(yamlContent) + } catch (e: Exception) { + Console.log("[TrailFileManager] Failed to parse YAML for prerequisite flattening: ${e.message}") + return yamlContent + } + + val config = trailblazeYaml.extractTrailConfig(trailItems) + + // Merge with NL definition config to pick up prerequisites defined in blaze.yaml + val effectiveConfig = if (variantFilePath != null) { + getEffectiveConfig(variantFilePath, config) + } else { + config + } + + // Track this trail in visited set to prevent circular dependency loops. + // We add both the config ID and the directory name (used for prerequisite lookups) + // so that cycle detection works even when a trail's config ID differs from its directory name. + val currentId = effectiveConfig?.id + if (currentId != null) { + if (currentId in visited) { + Console.log("[TrailFileManager] Circular dependency detected for '$currentId', skipping") + return yamlContent + } + visited.add(currentId) + } + val directoryName = variantFilePath?.let { File(it).parentFile?.name } + if (directoryName != null && directoryName !in visited) { + visited.add(directoryName) + } + + val prerequisites = effectiveConfig?.prerequisites + if (prerequisites.isNullOrEmpty()) return yamlContent + + Console.log("[TrailFileManager] Flattening ${prerequisites.size} prerequisite(s): ${prerequisites.joinToString(", ")}") + + // Collect all prerequisite prompt steps (in order) + val prereqPromptSteps = mutableListOf() + for (prereqId in prerequisites) { + if (prereqId in visited) { + Console.log("[TrailFileManager] Skipping already-visited prerequisite '$prereqId' (circular dependency)") + continue + } + + val prereqFile = findTrailByName(prereqId) + if (prereqFile == null) { + Console.log("[TrailFileManager] Warning: prerequisite trail '$prereqId' not found, skipping") + continue + } + + val prereqLoad = loadTrail(prereqFile) + if (!prereqLoad.success || prereqLoad.trailItems == null) { + Console.log("[TrailFileManager] Warning: failed to load prerequisite trail '$prereqId', skipping") + continue + } + + // Also recursively flatten the prerequisite's own prerequisites + val prereqConfig = prereqLoad.config + val prereqEffConfig = getEffectiveConfig(prereqFile, prereqConfig) + val nestedPrereqs = prereqEffConfig?.prerequisites + val prereqItems = if (!nestedPrereqs.isNullOrEmpty()) { + // Re-encode the prerequisite trail, flatten recursively, then re-decode + val prereqYaml = trailblazeYaml.encodeToString(prereqLoad.trailItems) + val flattenedPrereqYaml = flattenPrerequisitesRecursive(prereqYaml, prereqFile, visited) + try { + trailblazeYaml.decodeTrail(flattenedPrereqYaml) + } catch (_: Exception) { + prereqLoad.trailItems + } + } else { + prereqLoad.trailItems + } + + val steps = prereqItems + .filterIsInstance() + .flatMap { it.promptSteps } + prereqPromptSteps.addAll(steps) + Console.log("[TrailFileManager] Added ${steps.size} steps from prerequisite '$prereqId'") + } + + if (prereqPromptSteps.isEmpty()) return yamlContent + + // Reconstruct the trail items with prerequisite steps prepended + val mainPromptSteps = trailItems + .filterIsInstance() + .flatMap { it.promptSteps } + + val combinedSteps = prereqPromptSteps + mainPromptSteps + + // Rebuild trail items: config (without prerequisites, since they're now flattened) + // + combined prompts + any other non-prompt/non-config items + val newItems = mutableListOf() + + // Keep the config but clear prerequisites since they're now inline + val configWithoutPrereqs = effectiveConfig?.copy(prerequisites = null) + if (configWithoutPrereqs != null) { + newItems.add(TrailYamlItem.ConfigTrailItem(configWithoutPrereqs)) + } + + // Add the combined prompt steps + newItems.add(TrailYamlItem.PromptsTrailItem(combinedSteps)) + + // Preserve any tool items that aren't part of prompt steps + trailItems.filterIsInstance().forEach { newItems.add(it) } + + return try { + trailblazeYaml.encodeToString(newItems) + } catch (e: Exception) { + Console.log("[TrailFileManager] Failed to encode flattened YAML: ${e.message}") + yamlContent + } + } + // ───────────────────────────────────────────────────────────────────────────── // Trail editing // ───────────────────────────────────────────────────────────────────────────── @@ -383,6 +664,15 @@ class TrailFileManager( config: TrailConfig?, steps: List, ): EditResult { + // Validate prerequisites if present + val prereqErrors = validatePrerequisites(config) + if (prereqErrors.isNotEmpty()) { + return EditResult( + success = false, + error = "Invalid prerequisites: ${prereqErrors.joinToString("; ")}", + ) + } + return try { val file = validateWithinTrailsDir(File(filePath), filePath) val items = reconstructTrailItems(config, steps) diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailMcpTool.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailMcpTool.kt index 6f2e4c186..c11087bc7 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailMcpTool.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/newtools/TrailMcpTool.kt @@ -3,9 +3,11 @@ package xyz.block.trailblaze.mcp.newtools import ai.koog.agents.core.tools.annotations.LLMDescription import ai.koog.agents.core.tools.annotations.Tool import ai.koog.agents.core.tools.reflect.ToolSet +import kotlinx.datetime.Clock import kotlinx.serialization.Serializable import xyz.block.trailblaze.devices.TrailblazeDevicePlatform import xyz.block.trailblaze.logs.client.LogEmitter +import xyz.block.trailblaze.logs.client.TrailblazeLog import xyz.block.trailblaze.logs.client.TrailblazeJsonInstance import xyz.block.trailblaze.logs.model.SessionId import xyz.block.trailblaze.logs.model.getSessionStartedInfo @@ -360,7 +362,13 @@ class TrailMcpTool( val stepCount = loadResult.promptSteps?.size ?: 0 - // Connect to device if needed + // Get effective config by merging variant config with NL definition config. + // This ensures prerequisites (and other config fields) defined in blaze.yaml + // are available even when running a platform-specific .trail.yaml variant. + val effectiveConfig = trailFileManager.getEffectiveConfig(trailFile, loadResult.config) + + // Connect to device BEFORE prerequisites — both prerequisites and the main trail + // need a connected device to execute tool calls. if (platform != null || device != null) { val connectResult = connectToDevice(platform, device) if (connectResult.startsWith("Error")) { @@ -381,10 +389,24 @@ class TrailMcpTool( } } + // Auto-execute prerequisites before the main trail steps + val prerequisites = effectiveConfig?.prerequisites + if (!prerequisites.isNullOrEmpty()) { + val prereqResult = executePrerequisites( + prerequisites = prerequisites, + currentTrailId = effectiveConfig?.id ?: name ?: file ?: "unknown", + platform = platform, + device = device, + ) + if (prereqResult != null) { + return prereqResult + } + } + Console.log("") Console.log("┌──────────────────────────────────────────────────────────────────────────────") Console.log("│ [trail] Running: $trailFile") - Console.log("│ Title: ${loadResult.config?.title ?: "untitled"}") + Console.log("│ Title: ${effectiveConfig?.title ?: "untitled"}") Console.log("│ Steps: $stepCount") // Execute the trail deterministically using TrailExecutor @@ -397,7 +419,7 @@ class TrailMcpTool( } val executionResult = trailExecutor.execute( trailItems = trailItems, - trailName = loadResult.config?.title ?: File(trailFile).nameWithoutExtension, + trailName = effectiveConfig?.title ?: File(trailFile).nameWithoutExtension, ) { progress -> Console.log("│ $progress") sessionContext?.sendIndeterminateProgressMessage(progress) @@ -478,6 +500,8 @@ class TrailMcpTool( MOVE, /** Strip recordings from steps so they run with AI next time */ CLEAR_RECORDING, + /** Set prerequisite trail IDs that must run before this trail */ + SET_PREREQUISITES, } @LLMDescription( @@ -494,6 +518,7 @@ class TrailMcpTool( - DELETE: Remove steps - MOVE: Reorder a step - CLEAR_RECORDING: Strip recordings so steps run with AI on next execution + - SET_PREREQUISITES: Set prerequisite trail IDs that auto-run before this trail Workflow: Edit prompts → Run trail (AI handles unrecorded steps) → Save to capture recordings @@ -505,6 +530,8 @@ class TrailMcpTool( - trailEdit(operation=MOVE, name="login_flow", index=5, position=2) - trailEdit(operation=CLEAR_RECORDING, name="login_flow", index=3) - trailEdit(operation=CLEAR_RECORDING, name="login_flow") → clears ALL recordings + - trailEdit(operation=SET_PREREQUISITES, name="checkout_flow", prerequisites=["login_flow", "add_to_cart"]) + - trailEdit(operation=SET_PREREQUISITES, name="checkout_flow", prerequisites=[]) → clears prerequisites """ ) @Tool(McpToolProfile.TOOL_TRAIL_EDIT) @@ -523,6 +550,8 @@ class TrailMcpTool( prompt: String? = null, @LLMDescription("Step type: 'step' (default) or 'verify'") stepType: String? = null, + @LLMDescription("List of prerequisite trail IDs (for SET_PREREQUISITES). Pass empty list to clear.") + prerequisites: List? = null, ): String { // Resolve trail file val trailFile = trailFileManager.findTrailByName(name) @@ -538,6 +567,7 @@ class TrailMcpTool( TrailEditOperation.DELETE -> handleEditDelete(trailFile, index, count) TrailEditOperation.MOVE -> handleEditMove(trailFile, index, position) TrailEditOperation.CLEAR_RECORDING -> handleEditClearRecording(trailFile, index, count) + TrailEditOperation.SET_PREREQUISITES -> handleSetPrerequisites(trailFile, name, prerequisites) } } @@ -563,6 +593,7 @@ class TrailMcpTool( success = true, file = trailFile, title = config?.title, + prerequisites = config?.prerequisites, steps = stepInfos, totalSteps = steps.size, recordedSteps = recorded, @@ -848,10 +879,228 @@ class TrailMcpTool( } } + private fun handleSetPrerequisites( + trailFile: String, + trailName: String, + prerequisites: List?, + ): String { + if (prerequisites == null) { + return TrailEditResult( + success = false, + error = "Missing prerequisites parameter. Example: trailEdit(operation=SET_PREREQUISITES, name='...', prerequisites=['login_flow'])", + ).toJson() + } + + val (config, steps) = trailFileManager.getEditableSteps(trailFile) + ?: return TrailEditResult(success = false, error = "Failed to load trail").toJson() + + val trailId = config?.id ?: trailName + + if (prerequisites.isNotEmpty()) { + // Validate all prerequisite IDs exist + val validationErrors = trailFileManager.validatePrerequisites( + config?.copy(prerequisites = prerequisites) + ?: TrailConfig(prerequisites = prerequisites), + ) + if (validationErrors.isNotEmpty()) { + return TrailEditResult( + success = false, + error = validationErrors.joinToString("; "), + ).toJson() + } + + // Check for circular dependencies + val circularError = trailFileManager.detectCircularDependency(trailId, prerequisites) + if (circularError != null) { + return TrailEditResult( + success = false, + error = circularError, + ).toJson() + } + } + + // Update config with new prerequisites + val updatedConfig = (config ?: TrailConfig(id = trailId)).copy( + prerequisites = prerequisites.ifEmpty { null }, + ) + + val result = trailFileManager.saveEditedSteps(trailFile, updatedConfig, steps) + return if (result.success) { + val changeDesc = if (prerequisites.isEmpty()) { + "Cleared all prerequisites" + } else { + "Set prerequisites: ${prerequisites.joinToString(", ")}" + } + TrailEditResult( + success = true, + file = trailFile, + totalSteps = result.totalSteps, + recordedSteps = result.recordedSteps, + unrecordedSteps = result.unrecordedSteps, + changes = listOf(changeDesc), + message = if (prerequisites.isEmpty()) { + "Prerequisites cleared." + } else { + "Prerequisites set. These trails will auto-run before this trail on RUN." + }, + ).toJson() + } else { + TrailEditResult(success = false, error = result.error).toJson() + } + } + // ───────────────────────────────────────────────────────────────────────────── // Helper methods // ───────────────────────────────────────────────────────────────────────────── + /** + * Auto-executes prerequisite trails before the main trail. + * + * @param prerequisites List of prerequisite trail IDs to execute + * @param currentTrailId ID of the current trail (for circular dependency detection) + * @param platform Optional platform for device connection + * @param device Optional specific device ID + * @return An error JSON string if any prerequisite fails, or null if all succeeded + */ + private suspend fun executePrerequisites( + prerequisites: List, + currentTrailId: String, + platform: TrailblazeDevicePlatform?, + device: String?, + visited: MutableSet = mutableSetOf(), + ): String? { + visited.add(currentTrailId) + + Console.log("│ Prerequisites: ${prerequisites.joinToString(", ")}") + + // Check for circular dependencies before executing any prerequisites + val circularError = trailFileManager.detectCircularDependency(currentTrailId, prerequisites) + if (circularError != null) { + return TrailRunResult( + success = false, + error = circularError, + ).toJson() + } + + for (prereqId in prerequisites) { + val prereqFile = trailFileManager.findTrailByName(prereqId) + if (prereqFile == null) { + return TrailRunResult( + success = false, + error = "Prerequisite trail '$prereqId' not found. Use trail(action=LIST) to see available trails.", + ).toJson() + } + + val prereqLoad = trailFileManager.loadTrail(prereqFile) + if (!prereqLoad.success) { + return TrailRunResult( + success = false, + error = "Failed to load prerequisite trail '$prereqId': ${prereqLoad.error}", + ).toJson() + } + + val prereqEffectiveConfig = trailFileManager.getEffectiveConfig(prereqFile, prereqLoad.config) + + // Recursively execute nested prerequisites (if any) before running this one + val nestedPrereqs = prereqEffectiveConfig?.prerequisites + ?.filter { it !in visited } + if (!nestedPrereqs.isNullOrEmpty()) { + val nestedResult = executePrerequisites( + prerequisites = nestedPrereqs, + currentTrailId = prereqId, + platform = platform, + device = device, + visited = visited, + ) + if (nestedResult != null) { + return nestedResult + } + } + + Console.log("│ Running prerequisite: $prereqId") + + val prereqTitle = prereqEffectiveConfig?.title + val sessionId = sessionIdProvider?.invoke() + + // Emit prerequisite start log + if (sessionId != null) { + logEmitter?.emit( + TrailblazeLog.PrerequisiteStartLog( + prerequisiteTrailId = prereqId, + prerequisiteTitle = prereqTitle, + parentTrailId = currentTrailId, + session = sessionId, + timestamp = Clock.System.now(), + ) + ) + } + + val prereqStartTime = Clock.System.now() + + // Execute the prerequisite trail + val prereqItems = prereqLoad.trailItems + if (prereqItems == null) { + // Emit failure log before returning + if (sessionId != null) { + logEmitter?.emit( + TrailblazeLog.PrerequisiteCompleteLog( + prerequisiteTrailId = prereqId, + prerequisiteTitle = prereqTitle, + parentTrailId = currentTrailId, + passed = false, + stepsExecuted = 0, + durationMs = (Clock.System.now() - prereqStartTime).inWholeMilliseconds, + failureReason = "Prerequisite trail has no executable items", + session = sessionId, + timestamp = Clock.System.now(), + ) + ) + } + return TrailRunResult( + success = false, + error = "Prerequisite trail '$prereqId' has no executable items", + ).toJson() + } + + val prereqResult = trailExecutor.execute( + trailItems = prereqItems, + trailName = prereqTitle ?: prereqId, + ) { progress -> + Console.log("│ [prereq] $progress") + sessionContext?.sendIndeterminateProgressMessage("[prereq: $prereqId] $progress") + } + + // Emit prerequisite complete log + if (sessionId != null) { + logEmitter?.emit( + TrailblazeLog.PrerequisiteCompleteLog( + prerequisiteTrailId = prereqId, + prerequisiteTitle = prereqTitle, + parentTrailId = currentTrailId, + passed = prereqResult.passed, + stepsExecuted = prereqResult.stepsExecuted, + durationMs = prereqResult.durationMs, + failureReason = prereqResult.failureReason, + session = sessionId, + timestamp = Clock.System.now(), + ) + ) + } + + if (!prereqResult.passed) { + Console.log("│ Prerequisite '$prereqId' FAILED at step ${prereqResult.failedAtStep}: ${prereqResult.failureReason}") + return TrailRunResult( + success = false, + error = "Prerequisite trail '$prereqId' failed at step ${prereqResult.failedAtStep}: ${prereqResult.failureReason}", + ).toJson() + } + + Console.log("│ Prerequisite '$prereqId' PASSED (${prereqResult.stepsExecuted} steps, ${prereqResult.durationMs}ms)") + } + + return null // All prerequisites passed + } + private suspend fun connectToDevice( platform: TrailblazeDevicePlatform?, deviceId: String?, @@ -972,6 +1221,7 @@ data class TrailEditGetResult( val success: Boolean, val file: String? = null, val title: String? = null, + val prerequisites: List? = null, val steps: List? = null, val totalSteps: Int? = null, val recordedSteps: Int? = null, diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpProgressNotifier.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpProgressNotifier.kt index de0f8d16b..595085cd4 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpProgressNotifier.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpProgressNotifier.kt @@ -182,6 +182,21 @@ class McpProgressNotifier( ) } + is TrailblazeLog.PrerequisiteStartLog -> { + ProgressNotificationData( + message = "Running prerequisite: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}", + category = "prerequisite", + ) + } + + is TrailblazeLog.PrerequisiteCompleteLog -> { + val status = if (log.passed) "PASSED" else "FAILED" + ProgressNotificationData( + message = "Prerequisite ${log.prerequisiteTitle ?: log.prerequisiteTrailId}: $status (${log.durationMs}ms)", + category = "prerequisite", + ) + } + // Log types we don't surface as progress notifications (yet) is TrailblazeLog.AccessibilityActionLog, is TrailblazeLog.TrailblazeAgentTaskStatusChangeLog, diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogCardComposable.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogCardComposable.kt index e840c8035..61dc3b29f 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogCardComposable.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogCardComposable.kt @@ -138,6 +138,26 @@ fun LogCard( } ) + is TrailblazeLog.PrerequisiteStartLog -> LogCardData( + title = "Prerequisite Start: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}", + duration = null, + elapsedTime = elapsedTimeMs, + preformattedText = "Running prerequisite '${log.prerequisiteTrailId}' for trail '${log.parentTrailId}'" + ) + + is TrailblazeLog.PrerequisiteCompleteLog -> LogCardData( + title = if (log.passed) "Prerequisite Passed: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}" + else "Prerequisite Failed: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}", + duration = log.durationMs, + elapsedTime = elapsedTimeMs, + preformattedText = buildString { + appendLine("Trail: ${log.prerequisiteTrailId}") + appendLine("Status: ${if (log.passed) "PASSED" else "FAILED"}") + appendLine("Steps Executed: ${log.stepsExecuted}") + log.failureReason?.let { appendLine("Failure: $it") } + } + ) + is TrailblazeLog.TrailblazeSessionStatusChangeLog -> LogCardData( title = "Session Status", duration = null, diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogListRow.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogListRow.kt index 9657286ba..923d9b381 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogListRow.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/LogListRow.kt @@ -199,6 +199,18 @@ fun LogListRow( duration = null, elapsedTime = elapsedTimeMs ) + + is TrailblazeLog.PrerequisiteStartLog -> LogCardData( + title = "Prerequisite Start: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}", + duration = null, + elapsedTime = elapsedTimeMs + ) + + is TrailblazeLog.PrerequisiteCompleteLog -> LogCardData( + title = if (log.passed) "Prerequisite Passed" else "Prerequisite Failed", + duration = log.durationMs, + elapsedTime = elapsedTimeMs + ) } val elapsedMs = log.timestamp.toEpochMilliseconds() - sessionStartTime.toEpochMilliseconds() diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionProgressHelpers.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionProgressHelpers.kt index c0e6fa89e..611bdc131 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionProgressHelpers.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionProgressHelpers.kt @@ -468,6 +468,11 @@ internal fun latestActivityLabel(log: TrailblazeLog): String { is TrailblazeLog.McpToolCallResponseLog -> "MCP tool response: ${log.toolName}" is TrailblazeLog.McpAskLog -> "MCP ask" is TrailblazeLog.TrailblazeProgressLog -> log.description + is TrailblazeLog.PrerequisiteStartLog -> + "Running prerequisite: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}" + is TrailblazeLog.PrerequisiteCompleteLog -> + if (log.passed) "Prerequisite passed: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}" + else "Prerequisite failed: ${log.prerequisiteTitle ?: log.prerequisiteTrailId}" } } diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionTimelineView.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionTimelineView.kt index baeca9365..a57988012 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionTimelineView.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/SessionTimelineView.kt @@ -696,6 +696,8 @@ private fun logSummary(log: TrailblazeLog): Pair = is TrailblazeLog.TrailblazeLlmRequestLog -> "LLM Request" to "${log.durationMs}ms" is TrailblazeLog.ObjectiveStartLog -> "Step started" to log.promptStep.prompt is TrailblazeLog.ObjectiveCompleteLog -> "Step completed" to log.promptStep.prompt + is TrailblazeLog.PrerequisiteStartLog -> "Prerequisite started" to (log.prerequisiteTitle ?: log.prerequisiteTrailId) + is TrailblazeLog.PrerequisiteCompleteLog -> (if (log.passed) "Prerequisite passed" else "Prerequisite failed") to "${log.prerequisiteTitle ?: log.prerequisiteTrailId} (${log.durationMs}ms)" is TrailblazeLog.TrailblazeSessionStatusChangeLog -> "Session" to log.sessionStatus.toString() else -> log::class.simpleName.orEmpty() to null } diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/FlatLogComposable.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/FlatLogComposable.kt index 72356b824..7c781086e 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/FlatLogComposable.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/FlatLogComposable.kt @@ -311,6 +311,60 @@ fun ObjectiveCompleteDetailsFlat(log: TrailblazeLog.ObjectiveCompleteLog) { } } +@Composable +fun PrerequisiteStartDetailsFlat(log: TrailblazeLog.PrerequisiteStartLog) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + DetailSection("Prerequisite Trail") { + CodeBlock(log.prerequisiteTitle?.let { "$it (${log.prerequisiteTrailId})" } ?: log.prerequisiteTrailId) + } + DetailSection("Parent Trail") { + CodeBlock(log.parentTrailId) + } + } +} + +@Composable +fun PrerequisiteCompleteDetailsFlat(log: TrailblazeLog.PrerequisiteCompleteLog) { + val statusText = if (log.passed) "Passed" else "Failed" + val statusColor = if (log.passed) Color(0xFF28A745) else Color(0xFFDC3545) + + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + // Status indicator at the top + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 8.dp) + ) { + Icon( + imageVector = if (log.passed) Icons.Filled.CheckCircle else Icons.Filled.Close, + contentDescription = statusText, + tint = statusColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Prerequisite $statusText", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = statusColor + ) + } + + DetailSection("Prerequisite Trail") { + CodeBlock(log.prerequisiteTitle?.let { "$it (${log.prerequisiteTrailId})" } ?: log.prerequisiteTrailId) + } + DetailSection("Parent Trail") { + CodeBlock(log.parentTrailId) + } + DetailSection("Execution Summary") { + CodeBlock(buildString { + appendLine("Steps Executed: ${log.stepsExecuted}") + appendLine("Duration: ${log.durationMs}ms") + log.failureReason?.let { appendLine("Failure Reason: $it") } + }) + } + } +} + @Composable fun AttemptAiFallbackFlat( log: TrailblazeLog.AttemptAiFallbackLog, diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/LogDetailsDialog.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/LogDetailsDialog.kt index 96889abc7..4644e8c08 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/LogDetailsDialog.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/tabs/session/group/LogDetailsDialog.kt @@ -169,6 +169,18 @@ fun LogDetailsDialog( } } + is TrailblazeLog.PrerequisiteStartLog -> { + item { + PrerequisiteStartDetailsFlat(log) + } + } + + is TrailblazeLog.PrerequisiteCompleteLog -> { + item { + PrerequisiteCompleteDetailsFlat(log) + } + } + is TrailblazeLog.AttemptAiFallbackLog -> { item { AttemptAiFallbackFlat( diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/ColorUtils.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/ColorUtils.kt index ff66af348..e183a9c9e 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/ColorUtils.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/ColorUtils.kt @@ -24,6 +24,8 @@ object ColorUtils { ) // yellow is TrailblazeLog.ObjectiveStartLog -> if (isSystemInDarkTheme) Color(0xFFC3883A) else Color(0xFFf5b042) // orange is TrailblazeLog.ObjectiveCompleteLog -> if (isSystemInDarkTheme) Color(0xFFC2689A) else Color(0xFFf49ac2) // magenta/pink + is TrailblazeLog.PrerequisiteStartLog -> if (isSystemInDarkTheme) Color(0xFF6B8E8A) else Color(0xFF88C9BF) // teal + is TrailblazeLog.PrerequisiteCompleteLog -> if (isSystemInDarkTheme) Color(0xFF5A8575) else Color(0xFF7AB89E) // darker teal is TrailblazeLog.AccessibilityActionLog -> if (isSystemInDarkTheme) Color(0xFF7BA3C4) else Color(0xFFadd8e6) // light blue else -> if (isSystemInDarkTheme) Color(0xFF666666) else Color(0xFF000000) // fallback color } diff --git a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/DisplayUtils.kt b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/DisplayUtils.kt index 5afddeb0e..86618a6e0 100644 --- a/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/DisplayUtils.kt +++ b/trailblaze-ui/src/commonMain/kotlin/xyz/block/trailblaze/ui/utils/DisplayUtils.kt @@ -15,6 +15,8 @@ object DisplayUtils { is TrailblazeLog.TrailblazeSessionStatusChangeLog -> "Session Status" is TrailblazeLog.ObjectiveStartLog -> "Objective Start" is TrailblazeLog.ObjectiveCompleteLog -> "Objective Complete" + is TrailblazeLog.PrerequisiteStartLog -> "Prerequisite Start" + is TrailblazeLog.PrerequisiteCompleteLog -> "Prerequisite Complete" is TrailblazeLog.AttemptAiFallbackLog -> "Attempt AI Fallback" is TrailblazeLog.TrailblazeSnapshotLog -> "Snapshot" is TrailblazeLog.AccessibilityActionLog -> "Accessibility Action" diff --git a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/editors/yaml/YamlVisualEditor.kt b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/editors/yaml/YamlVisualEditor.kt index 5900a4e19..6e077179a 100644 --- a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/editors/yaml/YamlVisualEditor.kt +++ b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/editors/yaml/YamlVisualEditor.kt @@ -86,6 +86,7 @@ fun YamlVisualEditor( onYamlContentChange: (String) -> Unit, visualEditorView: YamlVisualEditorView, onVisualEditorViewChange: (YamlVisualEditorView) -> Unit, + availableTrailIds: List = emptyList(), modifier: Modifier = Modifier, ) { // Parse YAML content @@ -181,6 +182,7 @@ fun YamlVisualEditor( updateYamlFromItems() }, onItemDelete = {}, + availableTrailIds = availableTrailIds, modifier = Modifier.fillMaxWidth() ) } @@ -294,6 +296,7 @@ private fun TrailYamlItemCard( onMoveDown: () -> Unit, onItemUpdate: (TrailYamlItem) -> Unit, onItemDelete: () -> Unit, + availableTrailIds: List = emptyList(), modifier: Modifier = Modifier, ) { var showDeleteDialog by remember { mutableStateOf(false) } @@ -357,6 +360,7 @@ private fun TrailYamlItemCard( is TrailYamlItem.ConfigTrailItem -> ConfigItemContent( item = item, onUpdate = { onItemUpdate(it) }, + availableTrailIds = availableTrailIds, ) is TrailYamlItem.PromptsTrailItem -> PromptsItemContent( @@ -394,6 +398,7 @@ private fun TrailYamlItemCard( private fun ConfigItemContent( item: TrailYamlItem.ConfigTrailItem, onUpdate: (TrailYamlItem.ConfigTrailItem) -> Unit, + availableTrailIds: List = emptyList(), ) { Text( text = "Configuration", @@ -593,6 +598,122 @@ private fun ConfigItemContent( } } } + + // Prerequisites section + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Prerequisites", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + FilledTonalButton( + onClick = { + val currentPrereqs = item.config.prerequisites ?: emptyList() + // Add an empty placeholder that the user will fill via the dropdown + val newPrereqs = currentPrereqs + "" + onUpdate(item.copy(config = item.config.copy(prerequisites = newPrereqs))) + } + ) { + Icon( + Icons.Filled.Add, + contentDescription = "Add Prerequisite", + modifier = Modifier.size(16.dp).padding(end = 4.dp) + ) + Text("Add") + } + } + + val prerequisites = item.config.prerequisites ?: emptyList() + // Filter available trails: exclude the current trail's own ID and already-selected prerequisites + val currentTrailId = item.config.id ?: "" + + prerequisites.forEachIndexed { prereqIndex, prereqId -> + var prereqDropdownExpanded by remember { mutableStateOf(prereqId.isBlank()) } + val selectableTrailIds = availableTrailIds.filter { trailId -> + trailId != currentTrailId && (trailId == prereqId || trailId !in prerequisites) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ExposedDropdownMenuBox( + expanded = prereqDropdownExpanded, + onExpandedChange = { prereqDropdownExpanded = it }, + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = prereqId, + onValueChange = { newValue -> + // Allow manual typing as well + val newPrereqs = prerequisites.toMutableList().apply { + set(prereqIndex, newValue) + } + onUpdate(item.copy(config = item.config.copy( + prerequisites = newPrereqs.filter { it.isNotBlank() }.takeIf { it.isNotEmpty() } + ))) + }, + label = { Text("Trail ID") }, + placeholder = { Text("Select or type a trail ID") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = prereqDropdownExpanded) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + if (selectableTrailIds.isNotEmpty()) { + ExposedDropdownMenu( + expanded = prereqDropdownExpanded, + onDismissRequest = { prereqDropdownExpanded = false } + ) { + selectableTrailIds.forEach { trailId -> + DropdownMenuItem( + text = { Text(trailId) }, + onClick = { + val newPrereqs = prerequisites.toMutableList().apply { + set(prereqIndex, trailId) + } + onUpdate(item.copy(config = item.config.copy( + prerequisites = newPrereqs.filter { it.isNotBlank() }.takeIf { it.isNotEmpty() } + ))) + prereqDropdownExpanded = false + } + ) + } + } + } + } + IconButton( + onClick = { + val newPrereqs = prerequisites.toMutableList().apply { removeAt(prereqIndex) } + onUpdate(item.copy(config = item.config.copy( + prerequisites = newPrereqs.takeIf { it.isNotEmpty() } + ))) + } + ) { + Icon( + Icons.Filled.Delete, + contentDescription = "Remove prerequisite", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + } + + if (prerequisites.isEmpty()) { + Text( + text = "No prerequisites. Add trails that should run before this one.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } /** diff --git a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/Trail.kt b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/Trail.kt index ab88f8336..cc1e68480 100644 --- a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/Trail.kt +++ b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/Trail.kt @@ -77,6 +77,12 @@ data class Trail( val source: TrailSource? get() = defaultVariant?.config?.source ?: variants.firstNotNullOfOrNull { it.config?.source } + /** + * Returns the prerequisites from the default variant's config, or from any variant if default has none. + */ + val prerequisites: List? + get() = defaultVariant?.config?.prerequisites ?: variants.firstNotNullOfOrNull { it.config?.prerequisites } + /** * Returns merged metadata from all variants (default variant takes precedence). */ diff --git a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailDetailsView.kt b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailDetailsView.kt index 7f9e30468..d7bfa2c07 100644 --- a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailDetailsView.kt +++ b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailDetailsView.kt @@ -146,6 +146,20 @@ fun TrailDetailsView( ) } + // Prerequisites (from config) + trail.prerequisites?.takeIf { it.isNotEmpty() }?.let { prereqs -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Prerequisites", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + prereqs.forEach { prereqId -> + DetailRow(label = "→", value = prereqId, isMonospace = true) + } + } + // Metadata (from config) if (trail.metadata.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailYamlEditorModal.kt b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailYamlEditorModal.kt index 7963fc11d..f064b92b3 100644 --- a/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailYamlEditorModal.kt +++ b/trailblaze-ui/src/jvmMain/kotlin/xyz/block/trailblaze/ui/tabs/trails/TrailYamlEditorModal.kt @@ -83,6 +83,7 @@ fun TrailYamlEditorModal( progressMessages: List = emptyList(), connectionStatus: DeviceConnectionStatus? = null, relativePath: String? = null, + availableTrailIds: List = emptyList(), ) { var localContent by remember(initialContent) { mutableStateOf(initialContent) } var validationError by remember { mutableStateOf(null) } @@ -198,6 +199,7 @@ fun TrailYamlEditorModal( onYamlContentChange = { localContent = it }, visualEditorView = visualEditorView, onVisualEditorViewChange = { visualEditorView = it }, + availableTrailIds = availableTrailIds, modifier = Modifier.weight(1f) ) }